[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 06:48:55 -06:00
parent 69a6a2c4ea
commit eff0f70b7f
7 changed files with 829 additions and 0 deletions

View File

@ -15,3 +15,4 @@ export * from './useMfa';
export * from './useWhatsApp'; export * from './useWhatsApp';
export * from './usePortfolio'; export * from './usePortfolio';
export * from './useGoals'; export * from './useGoals';
export * from './useMlm';

302
src/hooks/useMlm.ts Normal file
View File

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

View File

@ -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<PaginatedCommissions> => {
const response = await api.get<PaginatedCommissions>('/mlm/commissions', { params });
return response.data;
},
get: async (id: string): Promise<Commission> => {
const response = await api.get<Commission>(`/mlm/commissions/${id}`);
return response.data;
},
calculate: async (data: CalculateCommissionsDto): Promise<Commission[]> => {
const response = await api.post<Commission[]>('/mlm/commissions/calculate', data);
return response.data;
},
updateStatus: async (id: string, data: UpdateCommissionStatusDto): Promise<Commission> => {
const response = await api.patch<Commission>(`/mlm/commissions/${id}/status`, data);
return response.data;
},
getByLevel: async (nodeId?: string): Promise<CommissionsByLevel[]> => {
const response = await api.get<CommissionsByLevel[]>('/mlm/commissions/by-level', {
params: { nodeId },
});
return response.data;
},
};

View File

@ -0,0 +1,4 @@
export * from './structures.api';
export * from './ranks.api';
export * from './nodes.api';
export * from './commissions.api';

View File

@ -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<string, unknown>;
progress: Record<string, number>;
} | null;
}
// ─────────────────────────────────────────────
// API Service
// ─────────────────────────────────────────────
export const nodesApi = {
// Nodes CRUD
list: async (params?: NodeFilters): Promise<PaginatedNodes> => {
const response = await api.get<PaginatedNodes>('/mlm/nodes', { params });
return response.data;
},
get: async (id: string): Promise<Node> => {
const response = await api.get<Node>(`/mlm/nodes/${id}`);
return response.data;
},
create: async (data: CreateNodeDto): Promise<Node> => {
const response = await api.post<Node>('/mlm/nodes', data);
return response.data;
},
update: async (id: string, data: UpdateNodeDto): Promise<Node> => {
const response = await api.patch<Node>(`/mlm/nodes/${id}`, data);
return response.data;
},
updateStatus: async (id: string, data: UpdateNodeStatusDto): Promise<Node> => {
const response = await api.patch<Node>(`/mlm/nodes/${id}/status`, data);
return response.data;
},
// Network navigation
getDownline: async (nodeId: string, maxDepth?: number): Promise<Node[]> => {
const response = await api.get<Node[]>(`/mlm/nodes/${nodeId}/downline`, {
params: { maxDepth },
});
return response.data;
},
getUpline: async (nodeId: string): Promise<Node[]> => {
const response = await api.get<Node[]>(`/mlm/nodes/${nodeId}/upline`);
return response.data;
},
getTree: async (nodeId: string, maxDepth?: number): Promise<TreeNode> => {
const response = await api.get<TreeNode>(`/mlm/nodes/${nodeId}/tree`, {
params: { maxDepth },
});
return response.data;
},
// My Network
getMyDashboard: async (): Promise<MyNetworkSummary> => {
const response = await api.get<MyNetworkSummary>('/mlm/my/dashboard');
return response.data;
},
getMyNetwork: async (maxDepth?: number): Promise<TreeNode | null> => {
const response = await api.get<TreeNode | null>('/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<InviteLink> => {
const response = await api.post<InviteLink>('/mlm/my/invite');
return response.data;
},
};

View File

@ -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<CreateRankDto>;
export interface RankFilters {
structureId?: string;
isActive?: boolean;
}
// ─────────────────────────────────────────────
// API Service
// ─────────────────────────────────────────────
export const ranksApi = {
list: async (params?: RankFilters): Promise<Rank[]> => {
const response = await api.get<Rank[]>('/mlm/ranks', { params });
return response.data;
},
get: async (id: string): Promise<Rank> => {
const response = await api.get<Rank>(`/mlm/ranks/${id}`);
return response.data;
},
create: async (data: CreateRankDto): Promise<Rank> => {
const response = await api.post<Rank>('/mlm/ranks', data);
return response.data;
},
update: async (id: string, data: UpdateRankDto): Promise<Rank> => {
const response = await api.patch<Rank>(`/mlm/ranks/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/mlm/ranks/${id}`);
},
evaluate: async (structureId: string): Promise<number> => {
const response = await api.post<number>('/mlm/ranks/evaluate', null, {
params: { structureId },
});
return response.data;
},
};

View File

@ -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<CreateStructureDto>;
export interface StructureFilters {
type?: StructureType;
isActive?: boolean;
search?: string;
}
// ─────────────────────────────────────────────
// API Service
// ─────────────────────────────────────────────
export const structuresApi = {
list: async (params?: StructureFilters): Promise<Structure[]> => {
const response = await api.get<Structure[]>('/mlm/structures', { params });
return response.data;
},
get: async (id: string): Promise<Structure> => {
const response = await api.get<Structure>(`/mlm/structures/${id}`);
return response.data;
},
create: async (data: CreateStructureDto): Promise<Structure> => {
const response = await api.post<Structure>('/mlm/structures', data);
return response.data;
},
update: async (id: string, data: UpdateStructureDto): Promise<Structure> => {
const response = await api.patch<Structure>(`/mlm/structures/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/mlm/structures/${id}`);
},
};