[SAAS-018/020] feat: Complete Sales and Commissions modules implementation
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions

Sprint 2 - Sales Frontend:
- Add frontend services, hooks, and pages for Sales module
- Update router with Sales routes

Sprint 3 - Commissions Backend:
- Add Commissions module with entities, DTOs, services, controllers
- Add DDL schema for commissions tables
- Register CommissionsModule in AppModule

Sprint 4 - Commissions Frontend:
- Add frontend services, hooks, and pages for Commissions module
- Add dashboard, schemes, entries, periods, and my-earnings pages
- Update router with Commissions routes

Update submodule references: backend, database, frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 22:51:30 -06:00
parent fdc75d18b9
commit 2e6ecee8ea
56 changed files with 5648 additions and 16 deletions

View File

@ -27,6 +27,7 @@ import { EmailModule } from '@modules/email/email.module';
import { OnboardingModule } from '@modules/onboarding/onboarding.module';
import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module';
import { SalesModule } from '@modules/sales/sales.module';
import { CommissionsModule } from '@modules/commissions/commissions.module';
@Module({
imports: [
@ -86,6 +87,7 @@ import { SalesModule } from '@modules/sales/sales.module';
OnboardingModule,
WhatsAppModule,
SalesModule,
CommissionsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,55 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Entities
import { CommissionScheme } from './entities/commission-scheme.entity';
import { CommissionAssignment } from './entities/commission-assignment.entity';
import { CommissionEntry } from './entities/commission-entry.entity';
import { CommissionPeriod } from './entities/commission-period.entity';
// Services
import { SchemesService } from './services/schemes.service';
import { AssignmentsService } from './services/assignments.service';
import { EntriesService } from './services/entries.service';
import { PeriodsService } from './services/periods.service';
import { CommissionsDashboardService } from './services/commissions-dashboard.service';
// Controllers
import { SchemesController } from './controllers/schemes.controller';
import { AssignmentsController } from './controllers/assignments.controller';
import { EntriesController } from './controllers/entries.controller';
import { PeriodsController } from './controllers/periods.controller';
import { DashboardController } from './controllers/dashboard.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
CommissionScheme,
CommissionAssignment,
CommissionEntry,
CommissionPeriod,
]),
],
controllers: [
SchemesController,
AssignmentsController,
EntriesController,
PeriodsController,
DashboardController,
],
providers: [
SchemesService,
AssignmentsService,
EntriesService,
PeriodsService,
CommissionsDashboardService,
],
exports: [
SchemesService,
AssignmentsService,
EntriesService,
PeriodsService,
CommissionsDashboardService,
],
})
export class CommissionsModule {}

View File

@ -0,0 +1,139 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { AssignmentsService, AssignmentFilters, PaginationOptions } from '../services/assignments.service';
import { CreateAssignmentDto, UpdateAssignmentDto } from '../dto';
@ApiTags('Commissions - Assignments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('commissions/assignments')
export class AssignmentsController {
constructor(private readonly assignmentsService: AssignmentsService) {}
@Get()
@ApiOperation({ summary: 'Get all commission assignments' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'user_id', required: false, type: String })
@ApiQuery({ name: 'scheme_id', required: false, type: String })
@ApiQuery({ name: 'is_active', required: false, type: Boolean })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('user_id') user_id?: string,
@Query('scheme_id') scheme_id?: string,
@Query('is_active') is_active?: boolean,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
const filters: AssignmentFilters = { user_id, scheme_id, is_active };
const pagination: PaginationOptions = { page, limit, sortBy, sortOrder };
return this.assignmentsService.findAll(tenantId, filters, pagination);
}
@Get(':id')
@ApiOperation({ summary: 'Get an assignment by ID' })
@ApiResponse({ status: 200, description: 'Assignment found' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.assignmentsService.findOne(tenantId, id);
}
@Get('user/:userId')
@ApiOperation({ summary: 'Get assignments for a user' })
async findByUser(
@CurrentTenant() tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
return this.assignmentsService.findByUser(tenantId, userId);
}
@Get('user/:userId/active')
@ApiOperation({ summary: 'Get active scheme for a user' })
async getActiveScheme(
@CurrentTenant() tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
const assignment = await this.assignmentsService.getActiveScheme(tenantId, userId);
if (!assignment) {
return { message: 'No active commission scheme assigned to this user' };
}
return assignment;
}
@Get('scheme/:schemeId/users')
@ApiOperation({ summary: 'Get users assigned to a scheme' })
async getSchemeAssignees(
@CurrentTenant() tenantId: string,
@Param('schemeId', ParseUUIDPipe) schemeId: string,
) {
return this.assignmentsService.getSchemeAssignees(tenantId, schemeId);
}
@Post()
@ApiOperation({ summary: 'Assign a commission scheme to a user' })
@ApiResponse({ status: 201, description: 'Assignment created' })
@ApiResponse({ status: 400, description: 'Invalid scheme or duplicate assignment' })
async assign(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: CreateAssignmentDto,
) {
return this.assignmentsService.assign(tenantId, dto, userId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an assignment' })
@ApiResponse({ status: 200, description: 'Assignment updated' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAssignmentDto,
) {
return this.assignmentsService.update(tenantId, id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Remove an assignment (deactivate)' })
@ApiResponse({ status: 200, description: 'Assignment removed' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
async remove(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.assignmentsService.remove(tenantId, id);
return { message: 'Assignment removed successfully' };
}
@Post(':id/deactivate')
@ApiOperation({ summary: 'Deactivate an assignment' })
@ApiResponse({ status: 200, description: 'Assignment deactivated' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
async deactivate(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.assignmentsService.deactivate(tenantId, id);
}
}

View File

@ -0,0 +1,99 @@
import {
Controller,
Get,
Param,
Query,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { CommissionsDashboardService } from '../services/commissions-dashboard.service';
@ApiTags('Commissions - Dashboard')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('commissions/dashboard')
export class DashboardController {
constructor(private readonly dashboardService: CommissionsDashboardService) {}
@Get('summary')
@ApiOperation({ summary: 'Get commissions dashboard summary' })
@ApiResponse({ status: 200, description: 'Dashboard summary' })
async getSummary(@CurrentTenant() tenantId: string) {
return this.dashboardService.getSummary(tenantId);
}
@Get('by-user')
@ApiOperation({ summary: 'Get earnings breakdown by user' })
@ApiQuery({ name: 'date_from', required: false, type: String })
@ApiQuery({ name: 'date_to', required: false, type: String })
async getEarningsByUser(
@CurrentTenant() tenantId: string,
@Query('date_from') dateFrom?: string,
@Query('date_to') dateTo?: string,
) {
return this.dashboardService.getEarningsByUser(
tenantId,
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined,
);
}
@Get('by-period')
@ApiOperation({ summary: 'Get earnings breakdown by period' })
async getEarningsByPeriod(@CurrentTenant() tenantId: string) {
return this.dashboardService.getEarningsByPeriod(tenantId);
}
@Get('top-earners')
@ApiOperation({ summary: 'Get top earning users' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of results (default: 10)' })
@ApiQuery({ name: 'date_from', required: false, type: String })
@ApiQuery({ name: 'date_to', required: false, type: String })
async getTopEarners(
@CurrentTenant() tenantId: string,
@Query('limit') limit?: number,
@Query('date_from') dateFrom?: string,
@Query('date_to') dateTo?: string,
) {
return this.dashboardService.getTopEarners(
tenantId,
limit || 10,
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined,
);
}
@Get('my-earnings')
@ApiOperation({ summary: 'Get current user earnings' })
@ApiResponse({ status: 200, description: 'User earnings summary' })
async getMyEarnings(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
) {
return this.dashboardService.getMyEarnings(tenantId, userId);
}
@Get('user/:userId/earnings')
@ApiOperation({ summary: 'Get specific user earnings' })
@ApiResponse({ status: 200, description: 'User earnings summary' })
async getUserEarnings(
@CurrentTenant() tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
return this.dashboardService.getMyEarnings(tenantId, userId);
}
@Get('scheme/:schemeId/performance')
@ApiOperation({ summary: 'Get scheme performance metrics' })
@ApiResponse({ status: 200, description: 'Scheme performance' })
async getSchemePerformance(
@CurrentTenant() tenantId: string,
@Param('schemeId', ParseUUIDPipe) schemeId: string,
) {
return this.dashboardService.getSchemePerformance(tenantId, schemeId);
}
}

View File

@ -0,0 +1,207 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { EntriesService, EntryFilters, PaginationOptions } from '../services/entries.service';
import { CalculateCommissionDto, UpdateEntryStatusDto, BulkApproveDto, BulkRejectDto } from '../dto';
import { EntryStatus } from '../entities/commission-entry.entity';
@ApiTags('Commissions - Entries')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('commissions/entries')
export class EntriesController {
constructor(private readonly entriesService: EntriesService) {}
@Get()
@ApiOperation({ summary: 'Get all commission entries' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'user_id', required: false, type: String })
@ApiQuery({ name: 'scheme_id', required: false, type: String })
@ApiQuery({ name: 'period_id', required: false, type: String })
@ApiQuery({ name: 'status', required: false, enum: EntryStatus })
@ApiQuery({ name: 'reference_type', required: false, type: String })
@ApiQuery({ name: 'date_from', required: false, type: String })
@ApiQuery({ name: 'date_to', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('user_id') user_id?: string,
@Query('scheme_id') scheme_id?: string,
@Query('period_id') period_id?: string,
@Query('status') status?: EntryStatus,
@Query('reference_type') reference_type?: string,
@Query('date_from') date_from?: string,
@Query('date_to') date_to?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
const filters: EntryFilters = {
user_id,
scheme_id,
period_id,
status,
reference_type,
date_from: date_from ? new Date(date_from) : undefined,
date_to: date_to ? new Date(date_to) : undefined,
};
const pagination: PaginationOptions = { page, limit, sortBy, sortOrder };
return this.entriesService.findAll(tenantId, filters, pagination);
}
@Get('pending')
@ApiOperation({ summary: 'Get pending commission entries' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findPending(
@CurrentTenant() tenantId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.entriesService.findAll(
tenantId,
{ status: EntryStatus.PENDING },
{ page, limit, sortBy: 'created_at', sortOrder: 'ASC' },
);
}
@Get(':id')
@ApiOperation({ summary: 'Get a commission entry by ID' })
@ApiResponse({ status: 200, description: 'Entry found' })
@ApiResponse({ status: 404, description: 'Entry not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.entriesService.findOne(tenantId, id);
}
@Get('user/:userId')
@ApiOperation({ summary: 'Get commission entries for a user' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findByUser(
@CurrentTenant() tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.entriesService.findByUser(tenantId, userId, { page, limit });
}
@Get('period/:periodId')
@ApiOperation({ summary: 'Get commission entries for a period' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findByPeriod(
@CurrentTenant() tenantId: string,
@Param('periodId', ParseUUIDPipe) periodId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.entriesService.findByPeriod(tenantId, periodId, { page, limit });
}
@Get('reference/:type/:refId')
@ApiOperation({ summary: 'Get commission entry by reference' })
async findByReference(
@CurrentTenant() tenantId: string,
@Param('type') referenceType: string,
@Param('refId', ParseUUIDPipe) referenceId: string,
) {
const entry = await this.entriesService.findByReference(tenantId, referenceType, referenceId);
if (!entry) {
return { message: 'No commission entry found for this reference' };
}
return entry;
}
@Post('calculate')
@ApiOperation({ summary: 'Calculate and create a commission entry' })
@ApiResponse({ status: 201, description: 'Commission calculated and entry created' })
@ApiResponse({ status: 400, description: 'User has no active scheme or amount below threshold' })
async calculate(
@CurrentTenant() tenantId: string,
@Body() dto: CalculateCommissionDto,
) {
return this.entriesService.calculate(tenantId, dto, true);
}
@Post('simulate')
@ApiOperation({ summary: 'Simulate commission calculation (no entry created)' })
@ApiResponse({ status: 200, description: 'Commission calculated' })
@ApiResponse({ status: 400, description: 'User has no active scheme' })
async simulate(
@CurrentTenant() tenantId: string,
@Body() dto: CalculateCommissionDto,
) {
return this.entriesService.calculate(tenantId, dto, false);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Update commission entry status' })
@ApiResponse({ status: 200, description: 'Status updated' })
@ApiResponse({ status: 400, description: 'Entry is finalized and cannot be modified' })
@ApiResponse({ status: 404, description: 'Entry not found' })
async updateStatus(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEntryStatusDto,
) {
return this.entriesService.updateStatus(tenantId, id, dto, userId);
}
@Post('bulk-approve')
@ApiOperation({ summary: 'Approve multiple commission entries' })
@ApiResponse({ status: 200, description: 'Entries approved' })
async bulkApprove(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: BulkApproveDto,
) {
return this.entriesService.bulkApprove(tenantId, dto.entry_ids, userId);
}
@Post('bulk-reject')
@ApiOperation({ summary: 'Reject multiple commission entries' })
@ApiResponse({ status: 200, description: 'Entries rejected' })
async bulkReject(
@CurrentTenant() tenantId: string,
@Body() dto: BulkRejectDto,
) {
return this.entriesService.bulkReject(tenantId, dto.entry_ids, dto.reason);
}
@Post('cancel/:type/:refId')
@ApiOperation({ summary: 'Cancel commission entry by reference' })
@ApiResponse({ status: 200, description: 'Entry cancelled' })
@ApiResponse({ status: 400, description: 'Cannot cancel paid commission' })
async cancelByReference(
@CurrentTenant() tenantId: string,
@Param('type') referenceType: string,
@Param('refId', ParseUUIDPipe) referenceId: string,
@Body('reason') reason?: string,
) {
const entry = await this.entriesService.cancelByReference(tenantId, referenceType, referenceId, reason);
if (!entry) {
return { message: 'No commission entry found for this reference' };
}
return entry;
}
}

View File

@ -0,0 +1,5 @@
export * from './schemes.controller';
export * from './assignments.controller';
export * from './entries.controller';
export * from './periods.controller';
export * from './dashboard.controller';

View File

@ -0,0 +1,154 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { PeriodsService, PeriodFilters, PaginationOptions } from '../services/periods.service';
import { CreatePeriodDto, MarkPeriodPaidDto } from '../dto';
import { PeriodStatus } from '../entities/commission-period.entity';
@ApiTags('Commissions - Periods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('commissions/periods')
export class PeriodsController {
constructor(private readonly periodsService: PeriodsService) {}
@Get()
@ApiOperation({ summary: 'Get all commission periods' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, enum: PeriodStatus })
@ApiQuery({ name: 'date_from', required: false, type: String })
@ApiQuery({ name: 'date_to', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('status') status?: PeriodStatus,
@Query('date_from') date_from?: string,
@Query('date_to') date_to?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
const filters: PeriodFilters = {
status,
date_from: date_from ? new Date(date_from) : undefined,
date_to: date_to ? new Date(date_to) : undefined,
};
const pagination: PaginationOptions = { page, limit, sortBy, sortOrder };
return this.periodsService.findAll(tenantId, filters, pagination);
}
@Get('open')
@ApiOperation({ summary: 'Get current open period' })
async getOpenPeriod(@CurrentTenant() tenantId: string) {
const period = await this.periodsService.getOpenPeriod(tenantId);
if (!period) {
return { message: 'No open commission period' };
}
return period;
}
@Get(':id')
@ApiOperation({ summary: 'Get a period by ID' })
@ApiResponse({ status: 200, description: 'Period found' })
@ApiResponse({ status: 404, description: 'Period not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.periodsService.findOne(tenantId, id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Get period summary with breakdown' })
@ApiResponse({ status: 200, description: 'Period summary' })
@ApiResponse({ status: 404, description: 'Period not found' })
async getSummary(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.periodsService.getSummary(tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Create a new commission period' })
@ApiResponse({ status: 201, description: 'Period created' })
@ApiResponse({ status: 400, description: 'Period dates overlap with existing' })
async create(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: CreatePeriodDto,
) {
return this.periodsService.create(tenantId, dto, userId);
}
@Post(':id/close')
@ApiOperation({ summary: 'Close a period' })
@ApiResponse({ status: 200, description: 'Period closed' })
@ApiResponse({ status: 400, description: 'Period cannot be closed' })
@ApiResponse({ status: 404, description: 'Period not found' })
async close(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.periodsService.close(tenantId, id, userId);
}
@Post(':id/reopen')
@ApiOperation({ summary: 'Reopen a closed period' })
@ApiResponse({ status: 200, description: 'Period reopened' })
@ApiResponse({ status: 400, description: 'Only closed periods can be reopened' })
@ApiResponse({ status: 404, description: 'Period not found' })
async reopen(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.periodsService.reopen(tenantId, id);
}
@Post(':id/processing')
@ApiOperation({ summary: 'Mark period as processing' })
@ApiResponse({ status: 200, description: 'Period marked as processing' })
@ApiResponse({ status: 400, description: 'Only closed periods can be marked as processing' })
@ApiResponse({ status: 404, description: 'Period not found' })
async markAsProcessing(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.periodsService.markAsProcessing(tenantId, id);
}
@Post(':id/pay')
@ApiOperation({ summary: 'Mark period as paid' })
@ApiResponse({ status: 200, description: 'Period marked as paid' })
@ApiResponse({ status: 400, description: 'Period not ready for payment' })
@ApiResponse({ status: 404, description: 'Period not found' })
async markAsPaid(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MarkPeriodPaidDto,
) {
return this.periodsService.markAsPaid(
tenantId,
id,
userId,
dto.payment_reference,
dto.payment_notes,
);
}
}

View File

@ -0,0 +1,127 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SchemesService, SchemeFilters, PaginationOptions } from '../services/schemes.service';
import { CreateSchemeDto, UpdateSchemeDto } from '../dto';
import { SchemeType } from '../entities/commission-scheme.entity';
@ApiTags('Commissions - Schemes')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('commissions/schemes')
export class SchemesController {
constructor(private readonly schemesService: SchemesService) {}
@Get()
@ApiOperation({ summary: 'Get all commission schemes' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'type', required: false, enum: SchemeType })
@ApiQuery({ name: 'is_active', required: false, type: Boolean })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('type') type?: SchemeType,
@Query('is_active') is_active?: boolean,
@Query('search') search?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
const filters: SchemeFilters = { type, is_active, search };
const pagination: PaginationOptions = { page, limit, sortBy, sortOrder };
return this.schemesService.findAll(tenantId, filters, pagination);
}
@Get('active')
@ApiOperation({ summary: 'Get all active commission schemes' })
async findActive(@CurrentTenant() tenantId: string) {
return this.schemesService.findActive(tenantId);
}
@Get(':id')
@ApiOperation({ summary: 'Get a commission scheme by ID' })
@ApiResponse({ status: 200, description: 'Scheme found' })
@ApiResponse({ status: 404, description: 'Scheme not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.schemesService.findOne(tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Create a new commission scheme' })
@ApiResponse({ status: 201, description: 'Scheme created' })
async create(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: CreateSchemeDto,
) {
return this.schemesService.create(tenantId, dto, userId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a commission scheme' })
@ApiResponse({ status: 200, description: 'Scheme updated' })
@ApiResponse({ status: 404, description: 'Scheme not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateSchemeDto,
) {
return this.schemesService.update(tenantId, id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a commission scheme (soft delete)' })
@ApiResponse({ status: 200, description: 'Scheme deleted' })
@ApiResponse({ status: 404, description: 'Scheme not found' })
async remove(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.schemesService.remove(tenantId, id);
return { message: 'Scheme deleted successfully' };
}
@Post(':id/duplicate')
@ApiOperation({ summary: 'Duplicate a commission scheme' })
@ApiResponse({ status: 201, description: 'Scheme duplicated' })
@ApiResponse({ status: 404, description: 'Scheme not found' })
async duplicate(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('name') newName?: string,
) {
return this.schemesService.duplicate(tenantId, id, newName, userId);
}
@Post(':id/toggle-active')
@ApiOperation({ summary: 'Toggle scheme active status' })
@ApiResponse({ status: 200, description: 'Scheme status toggled' })
@ApiResponse({ status: 404, description: 'Scheme not found' })
async toggleActive(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.schemesService.toggleActive(tenantId, id);
}
}

View File

@ -0,0 +1,36 @@
import { IsString, IsOptional, IsNumber, IsUUID, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CalculateCommissionDto {
@ApiProperty({ description: 'User ID (salesperson who earned the commission)' })
@IsUUID()
user_id: string;
@ApiProperty({ description: 'Type of reference (e.g., "sale", "opportunity", "order")' })
@IsString()
reference_type: string;
@ApiProperty({ description: 'ID of the reference record' })
@IsUUID()
reference_id: string;
@ApiProperty({ description: 'Base amount to calculate commission from' })
@IsNumber()
@Min(0)
base_amount: number;
@ApiPropertyOptional({ description: 'Currency code', default: 'USD' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: 'Additional notes' })
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ description: 'Additional metadata (e.g., product info)' })
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,43 @@
import {
IsString,
IsOptional,
IsNumber,
IsBoolean,
IsDateString,
IsUUID,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAssignmentDto {
@ApiProperty({ description: 'User ID to assign the scheme to' })
@IsUUID()
user_id: string;
@ApiProperty({ description: 'Commission scheme ID' })
@IsUUID()
scheme_id: string;
@ApiPropertyOptional({ description: 'Assignment start date', default: 'Current timestamp' })
@IsOptional()
@IsDateString()
starts_at?: Date;
@ApiPropertyOptional({ description: 'Assignment end date (null for indefinite)' })
@IsOptional()
@IsDateString()
ends_at?: Date;
@ApiPropertyOptional({ description: 'Custom rate override for this user (0-100)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
custom_rate?: number;
@ApiPropertyOptional({ description: 'Whether the assignment is active', default: true })
@IsOptional()
@IsBoolean()
is_active?: boolean;
}

View File

@ -0,0 +1,33 @@
import { IsString, IsDateString, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePeriodDto {
@ApiProperty({ description: 'Period name (e.g., "January 2026", "Week 1-2026")' })
@IsString()
name: string;
@ApiProperty({ description: 'Period start date' })
@IsDateString()
starts_at: Date;
@ApiProperty({ description: 'Period end date' })
@IsDateString()
ends_at: Date;
@ApiPropertyOptional({ description: 'Currency for the period', default: 'USD' })
@IsOptional()
@IsString()
currency?: string;
}
export class MarkPeriodPaidDto {
@ApiPropertyOptional({ description: 'Payment reference (e.g., transaction ID)' })
@IsOptional()
@IsString()
payment_reference?: string;
@ApiPropertyOptional({ description: 'Payment notes' })
@IsOptional()
@IsString()
payment_notes?: string;
}

View File

@ -0,0 +1,103 @@
import {
IsString,
IsOptional,
IsEnum,
IsNumber,
IsBoolean,
IsArray,
ValidateNested,
Min,
Max,
IsUUID,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { SchemeType, AppliesTo, TierConfig } from '../entities/commission-scheme.entity';
export class TierConfigDto {
@ApiProperty({ description: 'Tier lower bound (inclusive)' })
@IsNumber()
@Min(0)
from: number;
@ApiPropertyOptional({ description: 'Tier upper bound (exclusive), null for unlimited' })
@IsOptional()
@IsNumber()
@Min(0)
to: number | null;
@ApiProperty({ description: 'Commission rate for this tier (0-100)' })
@IsNumber()
@Min(0)
@Max(100)
rate: number;
}
export class CreateSchemeDto {
@ApiProperty({ description: 'Scheme name' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Scheme description' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ enum: SchemeType, description: 'Commission calculation type', default: SchemeType.PERCENTAGE })
@IsEnum(SchemeType)
type: SchemeType;
@ApiPropertyOptional({ description: 'Commission rate for percentage type (0-100)', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
rate?: number;
@ApiPropertyOptional({ description: 'Fixed commission amount', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
fixed_amount?: number;
@ApiPropertyOptional({ description: 'Tier configurations for tiered type', type: [TierConfigDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TierConfigDto)
tiers?: TierConfig[];
@ApiPropertyOptional({ enum: AppliesTo, description: 'What the commission applies to', default: AppliesTo.ALL })
@IsOptional()
@IsEnum(AppliesTo)
applies_to?: AppliesTo;
@ApiPropertyOptional({ description: 'Product IDs when applies_to is "products"', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
product_ids?: string[];
@ApiPropertyOptional({ description: 'Category IDs when applies_to is "categories"', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
category_ids?: string[];
@ApiPropertyOptional({ description: 'Minimum sale amount to qualify for commission', default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
min_amount?: number;
@ApiPropertyOptional({ description: 'Maximum commission per sale (cap)' })
@IsOptional()
@IsNumber()
@Min(0)
max_amount?: number;
@ApiPropertyOptional({ description: 'Whether the scheme is active', default: true })
@IsOptional()
@IsBoolean()
is_active?: boolean;
}

View File

@ -0,0 +1,7 @@
export * from './create-scheme.dto';
export * from './update-scheme.dto';
export * from './create-assignment.dto';
export * from './update-assignment.dto';
export * from './calculate-commission.dto';
export * from './update-entry-status.dto';
export * from './create-period.dto';

View File

@ -0,0 +1,21 @@
import { IsOptional, IsNumber, IsBoolean, IsDateString, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateAssignmentDto {
@ApiPropertyOptional({ description: 'Assignment end date' })
@IsOptional()
@IsDateString()
ends_at?: Date;
@ApiPropertyOptional({ description: 'Custom rate override (0-100)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
custom_rate?: number;
@ApiPropertyOptional({ description: 'Whether the assignment is active' })
@IsOptional()
@IsBoolean()
is_active?: boolean;
}

View File

@ -0,0 +1,31 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { EntryStatus } from '../entities/commission-entry.entity';
export class UpdateEntryStatusDto {
@ApiProperty({ enum: EntryStatus, description: 'New status for the commission entry' })
@IsEnum(EntryStatus)
status: EntryStatus;
@ApiPropertyOptional({ description: 'Notes about the status change (e.g., rejection reason)' })
@IsOptional()
@IsString()
notes?: string;
}
export class BulkApproveDto {
@ApiProperty({ description: 'Array of entry IDs to approve', type: [String] })
@IsString({ each: true })
entry_ids: string[];
}
export class BulkRejectDto {
@ApiProperty({ description: 'Array of entry IDs to reject', type: [String] })
@IsString({ each: true })
entry_ids: string[];
@ApiPropertyOptional({ description: 'Reason for rejection' })
@IsOptional()
@IsString()
reason?: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateSchemeDto } from './create-scheme.dto';
export class UpdateSchemeDto extends PartialType(CreateSchemeDto) {}

View File

@ -0,0 +1,70 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { CommissionScheme } from './commission-scheme.entity';
@Entity({ name: 'assignments', schema: 'commissions' })
export class CommissionAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
@Column({ type: 'uuid' })
scheme_id: string;
@ManyToOne(() => CommissionScheme, (scheme) => scheme.assignments)
@JoinColumn({ name: 'scheme_id' })
scheme?: CommissionScheme;
@Column({ type: 'timestamp with time zone', default: () => 'NOW()' })
starts_at: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
ends_at: Date | null;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
custom_rate: number | null;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@Column({ type: 'uuid', nullable: true })
created_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
creator?: User;
// Computed: Check if assignment is currently active
get isCurrentlyActive(): boolean {
const now = new Date();
return (
this.is_active &&
this.starts_at <= now &&
(this.ends_at === null || this.ends_at > now)
);
}
// Computed: Get effective rate (custom or scheme default)
getEffectiveRate(schemeRate: number): number {
return this.custom_rate !== null ? Number(this.custom_rate) : schemeRate;
}
}

View File

@ -0,0 +1,126 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { CommissionScheme } from './commission-scheme.entity';
import { CommissionAssignment } from './commission-assignment.entity';
import { CommissionPeriod } from './commission-period.entity';
export enum EntryStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
PAID = 'paid',
CANCELLED = 'cancelled',
}
@Entity({ name: 'entries', schema: 'commissions' })
export class CommissionEntry {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
@Column({ type: 'uuid' })
scheme_id: string;
@ManyToOne(() => CommissionScheme)
@JoinColumn({ name: 'scheme_id' })
scheme?: CommissionScheme;
@Column({ type: 'uuid', nullable: true })
assignment_id: string | null;
@ManyToOne(() => CommissionAssignment, { nullable: true })
@JoinColumn({ name: 'assignment_id' })
assignment?: CommissionAssignment;
@Column({ type: 'varchar', length: 50 })
reference_type: string;
@Column({ type: 'uuid' })
reference_id: string;
@Column({ type: 'decimal', precision: 15, scale: 2 })
base_amount: number;
@Column({ type: 'decimal', precision: 5, scale: 2 })
rate_applied: number;
@Column({ type: 'decimal', precision: 15, scale: 2 })
commission_amount: number;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({
type: 'enum',
enum: EntryStatus,
default: EntryStatus.PENDING,
})
status: EntryStatus;
@Column({ type: 'uuid', nullable: true })
period_id: string | null;
@ManyToOne(() => CommissionPeriod, { nullable: true })
@JoinColumn({ name: 'period_id' })
period?: CommissionPeriod;
@Column({ type: 'timestamp with time zone', nullable: true })
paid_at: Date | null;
@Column({ type: 'varchar', length: 255, nullable: true })
payment_reference: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, unknown>;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at: Date;
@Column({ type: 'uuid', nullable: true })
approved_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'approved_by' })
approver?: User;
@Column({ type: 'timestamp with time zone', nullable: true })
approved_at: Date | null;
// Computed: Check if entry can be modified
get isModifiable(): boolean {
return this.status === EntryStatus.PENDING;
}
// Computed: Check if entry can be approved
get isApprovable(): boolean {
return this.status === EntryStatus.PENDING;
}
// Computed: Check if entry is finalized
get isFinalized(): boolean {
return [EntryStatus.PAID, EntryStatus.REJECTED, EntryStatus.CANCELLED].includes(this.status);
}
}

View File

@ -0,0 +1,112 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { CommissionEntry } from './commission-entry.entity';
export enum PeriodStatus {
OPEN = 'open',
CLOSED = 'closed',
PROCESSING = 'processing',
PAID = 'paid',
}
@Entity({ name: 'periods', schema: 'commissions' })
export class CommissionPeriod {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'timestamp with time zone' })
starts_at: Date;
@Column({ type: 'timestamp with time zone' })
ends_at: Date;
@Column({ type: 'int', default: 0 })
total_entries: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
total_amount: number;
@Column({ type: 'varchar', length: 3, default: 'USD' })
currency: string;
@Column({
type: 'enum',
enum: PeriodStatus,
default: PeriodStatus.OPEN,
})
status: PeriodStatus;
@Column({ type: 'timestamp with time zone', nullable: true })
closed_at: Date | null;
@Column({ type: 'uuid', nullable: true })
closed_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'closed_by' })
closedByUser?: User;
@Column({ type: 'timestamp with time zone', nullable: true })
paid_at: Date | null;
@Column({ type: 'uuid', nullable: true })
paid_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'paid_by' })
paidByUser?: User;
@Column({ type: 'varchar', length: 255, nullable: true })
payment_reference: string | null;
@Column({ type: 'text', nullable: true })
payment_notes: string | null;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@Column({ type: 'uuid', nullable: true })
created_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
creator?: User;
@OneToMany(() => CommissionEntry, (entry) => entry.period)
entries?: CommissionEntry[];
// Computed: Check if period is open for new entries
get isOpen(): boolean {
return this.status === PeriodStatus.OPEN;
}
// Computed: Check if period can be closed
get isCloseable(): boolean {
return this.status === PeriodStatus.OPEN;
}
// Computed: Check if period is ready for payment
get isPayable(): boolean {
return this.status === PeriodStatus.CLOSED || this.status === PeriodStatus.PROCESSING;
}
// Computed: Calculate period duration in days
get durationDays(): number {
const diff = this.ends_at.getTime() - this.starts_at.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
}

View File

@ -0,0 +1,117 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
import { CommissionAssignment } from './commission-assignment.entity';
export enum SchemeType {
PERCENTAGE = 'percentage',
FIXED = 'fixed',
TIERED = 'tiered',
}
export enum AppliesTo {
ALL = 'all',
PRODUCTS = 'products',
CATEGORIES = 'categories',
}
export interface TierConfig {
from: number;
to: number | null;
rate: number;
}
@Entity({ name: 'schemes', schema: 'commissions' })
export class CommissionScheme {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: SchemeType,
default: SchemeType.PERCENTAGE,
})
type: SchemeType;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
rate: number;
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
fixed_amount: number;
@Column({ type: 'jsonb', default: [] })
tiers: TierConfig[];
@Column({
type: 'enum',
enum: AppliesTo,
default: AppliesTo.ALL,
})
applies_to: AppliesTo;
@Column({ type: 'uuid', array: true, default: '{}' })
product_ids: string[];
@Column({ type: 'uuid', array: true, default: '{}' })
category_ids: string[];
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
min_amount: number;
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: true })
max_amount: number | null;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at: Date;
@Column({ type: 'uuid', nullable: true })
created_by: string | null;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
creator?: User;
@Column({ type: 'timestamp with time zone', nullable: true })
deleted_at: Date | null;
@OneToMany(() => CommissionAssignment, (assignment) => assignment.scheme)
assignments?: CommissionAssignment[];
// Computed: Get rate for a specific amount (useful for tiered schemes)
getRateForAmount(amount: number): number {
if (this.type !== SchemeType.TIERED || !this.tiers.length) {
return Number(this.rate);
}
for (const tier of this.tiers) {
if (amount >= tier.from && (tier.to === null || amount < tier.to)) {
return tier.rate;
}
}
return 0;
}
}

View File

@ -0,0 +1,4 @@
export * from './commission-scheme.entity';
export * from './commission-assignment.entity';
export * from './commission-entry.entity';
export * from './commission-period.entity';

View File

@ -0,0 +1,183 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, MoreThan, IsNull, Or } from 'typeorm';
import { CommissionAssignment } from '../entities/commission-assignment.entity';
import { CommissionScheme } from '../entities/commission-scheme.entity';
import { CreateAssignmentDto, UpdateAssignmentDto } from '../dto';
export interface AssignmentFilters {
user_id?: string;
scheme_id?: string;
is_active?: boolean;
}
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class AssignmentsService {
constructor(
@InjectRepository(CommissionAssignment)
private readonly assignmentRepository: Repository<CommissionAssignment>,
@InjectRepository(CommissionScheme)
private readonly schemeRepository: Repository<CommissionScheme>,
) {}
async findAll(
tenantId: string,
filters: AssignmentFilters = {},
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionAssignment>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination;
const skip = (page - 1) * limit;
const queryBuilder = this.assignmentRepository.createQueryBuilder('assignment')
.leftJoinAndSelect('assignment.user', 'user')
.leftJoinAndSelect('assignment.scheme', 'scheme')
.where('assignment.tenant_id = :tenantId', { tenantId });
if (filters.user_id) {
queryBuilder.andWhere('assignment.user_id = :userId', { userId: filters.user_id });
}
if (filters.scheme_id) {
queryBuilder.andWhere('assignment.scheme_id = :schemeId', { schemeId: filters.scheme_id });
}
if (filters.is_active !== undefined) {
queryBuilder.andWhere('assignment.is_active = :isActive', { isActive: filters.is_active });
}
const [data, total] = await queryBuilder
.orderBy(`assignment.${sortBy}`, sortOrder)
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, id: string): Promise<CommissionAssignment> {
const assignment = await this.assignmentRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['user', 'scheme', 'creator'],
});
if (!assignment) {
throw new NotFoundException(`Commission assignment with ID ${id} not found`);
}
return assignment;
}
async findByUser(tenantId: string, userId: string): Promise<CommissionAssignment[]> {
return this.assignmentRepository.find({
where: {
tenant_id: tenantId,
user_id: userId,
is_active: true,
},
relations: ['scheme'],
order: { starts_at: 'DESC' },
});
}
async getActiveScheme(tenantId: string, userId: string): Promise<CommissionAssignment | null> {
const now = new Date();
return this.assignmentRepository.findOne({
where: {
tenant_id: tenantId,
user_id: userId,
is_active: true,
starts_at: LessThanOrEqual(now),
ends_at: Or(IsNull(), MoreThan(now)),
},
relations: ['scheme'],
order: { starts_at: 'DESC' },
});
}
async assign(tenantId: string, dto: CreateAssignmentDto, createdBy?: string): Promise<CommissionAssignment> {
// Verify scheme exists and is active
const scheme = await this.schemeRepository.findOne({
where: { id: dto.scheme_id, tenant_id: tenantId, is_active: true, deleted_at: undefined },
});
if (!scheme) {
throw new BadRequestException('Invalid or inactive commission scheme');
}
// Check for overlapping active assignments
const existingAssignment = await this.getActiveScheme(tenantId, dto.user_id);
if (existingAssignment && existingAssignment.scheme_id === dto.scheme_id) {
throw new BadRequestException('User already has an active assignment for this scheme');
}
const assignment = this.assignmentRepository.create({
...dto,
tenant_id: tenantId,
created_by: createdBy,
});
return this.assignmentRepository.save(assignment);
}
async update(tenantId: string, id: string, dto: UpdateAssignmentDto): Promise<CommissionAssignment> {
const assignment = await this.findOne(tenantId, id);
if (dto.ends_at && dto.ends_at <= assignment.starts_at) {
throw new BadRequestException('End date must be after start date');
}
Object.assign(assignment, dto);
return this.assignmentRepository.save(assignment);
}
async remove(tenantId: string, id: string): Promise<void> {
const assignment = await this.findOne(tenantId, id);
assignment.is_active = false;
assignment.ends_at = new Date();
await this.assignmentRepository.save(assignment);
}
async deactivate(tenantId: string, id: string): Promise<CommissionAssignment> {
const assignment = await this.findOne(tenantId, id);
assignment.is_active = false;
assignment.ends_at = new Date();
return this.assignmentRepository.save(assignment);
}
async getSchemeAssignees(tenantId: string, schemeId: string): Promise<CommissionAssignment[]> {
const now = new Date();
return this.assignmentRepository.find({
where: {
tenant_id: tenantId,
scheme_id: schemeId,
is_active: true,
starts_at: LessThanOrEqual(now),
ends_at: Or(IsNull(), MoreThan(now)),
},
relations: ['user'],
});
}
}

View File

@ -0,0 +1,371 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity';
import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity';
import { CommissionScheme } from '../entities/commission-scheme.entity';
import { CommissionAssignment } from '../entities/commission-assignment.entity';
export interface DashboardSummary {
total_pending: number;
total_approved: number;
total_paid: number;
pending_count: number;
approved_count: number;
paid_count: number;
active_schemes: number;
active_assignments: number;
open_periods: number;
currency: string;
}
export interface UserEarnings {
user_id: string;
user_name: string;
user_email: string;
pending: number;
approved: number;
paid: number;
total: number;
entries_count: number;
}
export interface PeriodEarnings {
period_id: string;
period_name: string;
starts_at: Date;
ends_at: Date;
status: PeriodStatus;
total_amount: number;
entries_count: number;
}
export interface MyEarnings {
pending: number;
approved: number;
paid: number;
total: number;
current_period_earnings: number;
last_paid_date: Date | null;
entries_count: number;
recent_entries: CommissionEntry[];
}
export interface TopEarner {
user_id: string;
user_name: string;
total_earned: number;
entries_count: number;
rank: number;
}
@Injectable()
export class CommissionsDashboardService {
constructor(
@InjectRepository(CommissionEntry)
private readonly entryRepository: Repository<CommissionEntry>,
@InjectRepository(CommissionPeriod)
private readonly periodRepository: Repository<CommissionPeriod>,
@InjectRepository(CommissionScheme)
private readonly schemeRepository: Repository<CommissionScheme>,
@InjectRepository(CommissionAssignment)
private readonly assignmentRepository: Repository<CommissionAssignment>,
) {}
async getSummary(tenantId: string): Promise<DashboardSummary> {
// Get entry stats by status
const entryStats = await this.entryRepository
.createQueryBuilder('entry')
.select('entry.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.where('entry.tenant_id = :tenantId', { tenantId })
.groupBy('entry.status')
.getRawMany();
// Count active schemes
const activeSchemes = await this.schemeRepository.count({
where: { tenant_id: tenantId, is_active: true, deleted_at: undefined },
});
// Count active assignments
const now = new Date();
const activeAssignments = await this.assignmentRepository
.createQueryBuilder('assignment')
.where('assignment.tenant_id = :tenantId', { tenantId })
.andWhere('assignment.is_active = true')
.andWhere('assignment.starts_at <= :now', { now })
.andWhere('(assignment.ends_at IS NULL OR assignment.ends_at > :now)', { now })
.getCount();
// Count open periods
const openPeriods = await this.periodRepository.count({
where: { tenant_id: tenantId, status: PeriodStatus.OPEN },
});
const summary: DashboardSummary = {
total_pending: 0,
total_approved: 0,
total_paid: 0,
pending_count: 0,
approved_count: 0,
paid_count: 0,
active_schemes: activeSchemes,
active_assignments: activeAssignments,
open_periods: openPeriods,
currency: 'USD',
};
entryStats.forEach((stat) => {
const count = parseInt(stat.count);
const total = parseFloat(stat.total);
switch (stat.status) {
case EntryStatus.PENDING:
summary.total_pending = total;
summary.pending_count = count;
break;
case EntryStatus.APPROVED:
summary.total_approved = total;
summary.approved_count = count;
break;
case EntryStatus.PAID:
summary.total_paid = total;
summary.paid_count = count;
break;
}
});
return summary;
}
async getEarningsByUser(
tenantId: string,
dateFrom?: Date,
dateTo?: Date,
): Promise<UserEarnings[]> {
const queryBuilder = this.entryRepository
.createQueryBuilder('entry')
.leftJoin('entry.user', 'user')
.select('entry.user_id', 'user_id')
.addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name')
.addSelect('user.email', 'user_email')
.addSelect("COALESCE(SUM(CASE WHEN entry.status = 'pending' THEN entry.commission_amount ELSE 0 END), 0)", 'pending')
.addSelect("COALESCE(SUM(CASE WHEN entry.status = 'approved' THEN entry.commission_amount ELSE 0 END), 0)", 'approved')
.addSelect("COALESCE(SUM(CASE WHEN entry.status = 'paid' THEN entry.commission_amount ELSE 0 END), 0)", 'paid')
.addSelect('COUNT(*)', 'entries_count')
.where('entry.tenant_id = :tenantId', { tenantId })
.andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] });
if (dateFrom) {
queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo });
}
const results = await queryBuilder
.groupBy('entry.user_id')
.addGroupBy('user.first_name')
.addGroupBy('user.last_name')
.addGroupBy('user.email')
.orderBy('paid', 'DESC')
.getRawMany();
return results.map((row) => ({
user_id: row.user_id,
user_name: row.user_name || 'Unknown',
user_email: row.user_email || '',
pending: parseFloat(row.pending),
approved: parseFloat(row.approved),
paid: parseFloat(row.paid),
total: parseFloat(row.pending) + parseFloat(row.approved) + parseFloat(row.paid),
entries_count: parseInt(row.entries_count),
}));
}
async getEarningsByPeriod(tenantId: string): Promise<PeriodEarnings[]> {
const periods = await this.periodRepository.find({
where: { tenant_id: tenantId },
order: { starts_at: 'DESC' },
take: 12, // Last 12 periods
});
const results: PeriodEarnings[] = [];
for (const period of periods) {
const stats = await this.entryRepository
.createQueryBuilder('entry')
.select('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.where('entry.period_id = :periodId', { periodId: period.id })
.andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] })
.getRawOne();
results.push({
period_id: period.id,
period_name: period.name,
starts_at: period.starts_at,
ends_at: period.ends_at,
status: period.status,
total_amount: parseFloat(stats.total) || 0,
entries_count: parseInt(stats.count) || 0,
});
}
return results;
}
async getTopEarners(
tenantId: string,
limit: number = 10,
dateFrom?: Date,
dateTo?: Date,
): Promise<TopEarner[]> {
const queryBuilder = this.entryRepository
.createQueryBuilder('entry')
.leftJoin('entry.user', 'user')
.select('entry.user_id', 'user_id')
.addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total_earned')
.addSelect('COUNT(*)', 'entries_count')
.where('entry.tenant_id = :tenantId', { tenantId })
.andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] });
if (dateFrom) {
queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo });
}
const results = await queryBuilder
.groupBy('entry.user_id')
.addGroupBy('user.first_name')
.addGroupBy('user.last_name')
.orderBy('total_earned', 'DESC')
.limit(limit)
.getRawMany();
return results.map((row, index) => ({
user_id: row.user_id,
user_name: row.user_name || 'Unknown',
total_earned: parseFloat(row.total_earned),
entries_count: parseInt(row.entries_count),
rank: index + 1,
}));
}
async getMyEarnings(tenantId: string, userId: string): Promise<MyEarnings> {
// Get totals by status
const totals = await this.entryRepository
.createQueryBuilder('entry')
.select('entry.status', 'status')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.addSelect('COUNT(*)', 'count')
.where('entry.tenant_id = :tenantId', { tenantId })
.andWhere('entry.user_id = :userId', { userId })
.groupBy('entry.status')
.getRawMany();
// Get current period earnings
const openPeriod = await this.periodRepository.findOne({
where: { tenant_id: tenantId, status: PeriodStatus.OPEN },
});
let currentPeriodEarnings = 0;
if (openPeriod) {
const periodStats = await this.entryRepository
.createQueryBuilder('entry')
.select('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.where('entry.period_id = :periodId', { periodId: openPeriod.id })
.andWhere('entry.user_id = :userId', { userId })
.andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] })
.getRawOne();
currentPeriodEarnings = parseFloat(periodStats.total) || 0;
}
// Get last paid date
const lastPaid = await this.entryRepository.findOne({
where: { tenant_id: tenantId, user_id: userId, status: EntryStatus.PAID },
order: { paid_at: 'DESC' },
});
// Get recent entries
const recentEntries = await this.entryRepository.find({
where: { tenant_id: tenantId, user_id: userId },
relations: ['scheme'],
order: { created_at: 'DESC' },
take: 10,
});
const result: MyEarnings = {
pending: 0,
approved: 0,
paid: 0,
total: 0,
current_period_earnings: currentPeriodEarnings,
last_paid_date: lastPaid?.paid_at || null,
entries_count: 0,
recent_entries: recentEntries,
};
totals.forEach((row) => {
const total = parseFloat(row.total);
const count = parseInt(row.count);
switch (row.status) {
case EntryStatus.PENDING:
result.pending = total;
result.entries_count += count;
break;
case EntryStatus.APPROVED:
result.approved = total;
result.entries_count += count;
break;
case EntryStatus.PAID:
result.paid = total;
result.entries_count += count;
break;
}
});
result.total = result.pending + result.approved + result.paid;
return result;
}
async getSchemePerformance(
tenantId: string,
schemeId: string,
): Promise<{
total_generated: number;
total_paid: number;
entries_count: number;
avg_commission: number;
active_users: number;
}> {
const stats = await this.entryRepository
.createQueryBuilder('entry')
.select('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.addSelect("COALESCE(SUM(CASE WHEN entry.status = 'paid' THEN entry.commission_amount ELSE 0 END), 0)", 'paid')
.addSelect('COALESCE(AVG(entry.commission_amount), 0)', 'avg')
.addSelect('COUNT(DISTINCT entry.user_id)', 'users')
.where('entry.tenant_id = :tenantId', { tenantId })
.andWhere('entry.scheme_id = :schemeId', { schemeId })
.andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] })
.getRawOne();
return {
total_generated: parseFloat(stats.total) || 0,
total_paid: parseFloat(stats.paid) || 0,
entries_count: parseInt(stats.count) || 0,
avg_commission: parseFloat(stats.avg) || 0,
active_users: parseInt(stats.users) || 0,
};
}
}

View File

@ -0,0 +1,357 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity';
import { CommissionScheme, SchemeType } from '../entities/commission-scheme.entity';
import { CommissionAssignment } from '../entities/commission-assignment.entity';
import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity';
import { AssignmentsService } from './assignments.service';
import { CalculateCommissionDto, UpdateEntryStatusDto } from '../dto';
export interface EntryFilters {
user_id?: string;
scheme_id?: string;
period_id?: string;
status?: EntryStatus;
reference_type?: string;
date_from?: Date;
date_to?: Date;
}
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CalculationResult {
rate_applied: number;
commission_amount: number;
scheme_id: string;
assignment_id: string | null;
}
@Injectable()
export class EntriesService {
constructor(
@InjectRepository(CommissionEntry)
private readonly entryRepository: Repository<CommissionEntry>,
@InjectRepository(CommissionScheme)
private readonly schemeRepository: Repository<CommissionScheme>,
@InjectRepository(CommissionPeriod)
private readonly periodRepository: Repository<CommissionPeriod>,
private readonly assignmentsService: AssignmentsService,
) {}
async findAll(
tenantId: string,
filters: EntryFilters = {},
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionEntry>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination;
const skip = (page - 1) * limit;
const queryBuilder = this.entryRepository.createQueryBuilder('entry')
.leftJoinAndSelect('entry.user', 'user')
.leftJoinAndSelect('entry.scheme', 'scheme')
.leftJoinAndSelect('entry.period', 'period')
.where('entry.tenant_id = :tenantId', { tenantId });
if (filters.user_id) {
queryBuilder.andWhere('entry.user_id = :userId', { userId: filters.user_id });
}
if (filters.scheme_id) {
queryBuilder.andWhere('entry.scheme_id = :schemeId', { schemeId: filters.scheme_id });
}
if (filters.period_id) {
queryBuilder.andWhere('entry.period_id = :periodId', { periodId: filters.period_id });
}
if (filters.status) {
queryBuilder.andWhere('entry.status = :status', { status: filters.status });
}
if (filters.reference_type) {
queryBuilder.andWhere('entry.reference_type = :refType', { refType: filters.reference_type });
}
if (filters.date_from) {
queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom: filters.date_from });
}
if (filters.date_to) {
queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo: filters.date_to });
}
const [data, total] = await queryBuilder
.orderBy(`entry.${sortBy}`, sortOrder)
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, id: string): Promise<CommissionEntry> {
const entry = await this.entryRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['user', 'scheme', 'period', 'approver'],
});
if (!entry) {
throw new NotFoundException(`Commission entry with ID ${id} not found`);
}
return entry;
}
async findByUser(
tenantId: string,
userId: string,
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionEntry>> {
return this.findAll(tenantId, { user_id: userId }, pagination);
}
async findByPeriod(
tenantId: string,
periodId: string,
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionEntry>> {
return this.findAll(tenantId, { period_id: periodId }, pagination);
}
async calculate(
tenantId: string,
dto: CalculateCommissionDto,
createEntry: boolean = true,
): Promise<CommissionEntry | CalculationResult> {
// Get user's active assignment
const assignment = await this.assignmentsService.getActiveScheme(tenantId, dto.user_id);
if (!assignment || !assignment.scheme) {
throw new BadRequestException('User does not have an active commission scheme assigned');
}
const scheme = assignment.scheme;
// Check minimum amount threshold
if (dto.base_amount < Number(scheme.min_amount)) {
throw new BadRequestException(`Amount ${dto.base_amount} is below minimum threshold of ${scheme.min_amount}`);
}
// Calculate rate and commission
let rate: number;
let commissionAmount: number;
switch (scheme.type) {
case SchemeType.PERCENTAGE:
rate = assignment.custom_rate !== null ? Number(assignment.custom_rate) : Number(scheme.rate);
commissionAmount = dto.base_amount * (rate / 100);
break;
case SchemeType.FIXED:
rate = 0;
commissionAmount = Number(scheme.fixed_amount);
break;
case SchemeType.TIERED:
rate = scheme.getRateForAmount(dto.base_amount);
if (assignment.custom_rate !== null) {
rate = Number(assignment.custom_rate);
}
commissionAmount = dto.base_amount * (rate / 100);
break;
default:
throw new BadRequestException('Invalid scheme type');
}
// Apply maximum cap
if (scheme.max_amount !== null && commissionAmount > Number(scheme.max_amount)) {
commissionAmount = Number(scheme.max_amount);
}
commissionAmount = Math.round(commissionAmount * 100) / 100;
if (!createEntry) {
return {
rate_applied: rate,
commission_amount: commissionAmount,
scheme_id: scheme.id,
assignment_id: assignment.id,
};
}
// Get current open period
const openPeriod = await this.periodRepository.findOne({
where: { tenant_id: tenantId, status: PeriodStatus.OPEN },
order: { starts_at: 'DESC' },
});
// Create entry
const entry = this.entryRepository.create({
tenant_id: tenantId,
user_id: dto.user_id,
scheme_id: scheme.id,
assignment_id: assignment.id,
reference_type: dto.reference_type,
reference_id: dto.reference_id,
base_amount: dto.base_amount,
rate_applied: rate,
commission_amount: commissionAmount,
currency: dto.currency || 'USD',
status: EntryStatus.PENDING,
period_id: openPeriod?.id || null,
notes: dto.notes,
metadata: dto.metadata || {},
});
return this.entryRepository.save(entry);
}
async updateStatus(
tenantId: string,
id: string,
dto: UpdateEntryStatusDto,
approvedBy?: string,
): Promise<CommissionEntry> {
const entry = await this.findOne(tenantId, id);
if (entry.isFinalized) {
throw new BadRequestException('Cannot modify a finalized commission entry');
}
entry.status = dto.status;
if (dto.status === EntryStatus.APPROVED && approvedBy) {
entry.approved_by = approvedBy;
entry.approved_at = new Date();
}
if (dto.notes) {
entry.notes = entry.notes ? `${entry.notes}\n${dto.notes}` : dto.notes;
}
return this.entryRepository.save(entry);
}
async bulkApprove(
tenantId: string,
entryIds: string[],
approvedBy: string,
): Promise<{ approved: number; failed: number }> {
let approved = 0;
let failed = 0;
for (const id of entryIds) {
try {
const entry = await this.entryRepository.findOne({
where: { id, tenant_id: tenantId, status: EntryStatus.PENDING },
});
if (entry) {
entry.status = EntryStatus.APPROVED;
entry.approved_by = approvedBy;
entry.approved_at = new Date();
await this.entryRepository.save(entry);
approved++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { approved, failed };
}
async bulkReject(
tenantId: string,
entryIds: string[],
reason?: string,
): Promise<{ rejected: number; failed: number }> {
let rejected = 0;
let failed = 0;
for (const id of entryIds) {
try {
const entry = await this.entryRepository.findOne({
where: { id, tenant_id: tenantId, status: EntryStatus.PENDING },
});
if (entry) {
entry.status = EntryStatus.REJECTED;
if (reason) {
entry.notes = entry.notes ? `${entry.notes}\n[Rejected] ${reason}` : `[Rejected] ${reason}`;
}
await this.entryRepository.save(entry);
rejected++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { rejected, failed };
}
async findByReference(
tenantId: string,
referenceType: string,
referenceId: string,
): Promise<CommissionEntry | null> {
return this.entryRepository.findOne({
where: {
tenant_id: tenantId,
reference_type: referenceType,
reference_id: referenceId,
},
relations: ['user', 'scheme'],
});
}
async cancelByReference(
tenantId: string,
referenceType: string,
referenceId: string,
reason?: string,
): Promise<CommissionEntry | null> {
const entry = await this.findByReference(tenantId, referenceType, referenceId);
if (!entry) {
return null;
}
if (entry.status === EntryStatus.PAID) {
throw new BadRequestException('Cannot cancel a paid commission');
}
entry.status = EntryStatus.CANCELLED;
if (reason) {
entry.notes = entry.notes ? `${entry.notes}\n[Cancelled] ${reason}` : `[Cancelled] ${reason}`;
}
return this.entryRepository.save(entry);
}
}

View File

@ -0,0 +1,5 @@
export * from './schemes.service';
export * from './assignments.service';
export * from './entries.service';
export * from './periods.service';
export * from './commissions-dashboard.service';

View File

@ -0,0 +1,288 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity';
import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity';
import { CreatePeriodDto } from '../dto';
export interface PeriodFilters {
status?: PeriodStatus;
date_from?: Date;
date_to?: Date;
}
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PeriodSummary {
total_entries: number;
total_amount: number;
by_status: {
pending: number;
approved: number;
rejected: number;
paid: number;
cancelled: number;
};
by_user: Array<{
user_id: string;
user_name: string;
entries_count: number;
total_amount: number;
}>;
}
@Injectable()
export class PeriodsService {
constructor(
@InjectRepository(CommissionPeriod)
private readonly periodRepository: Repository<CommissionPeriod>,
@InjectRepository(CommissionEntry)
private readonly entryRepository: Repository<CommissionEntry>,
) {}
async findAll(
tenantId: string,
filters: PeriodFilters = {},
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionPeriod>> {
const { page = 1, limit = 20, sortBy = 'starts_at', sortOrder = 'DESC' } = pagination;
const skip = (page - 1) * limit;
const queryBuilder = this.periodRepository.createQueryBuilder('period')
.leftJoinAndSelect('period.creator', 'creator')
.where('period.tenant_id = :tenantId', { tenantId });
if (filters.status) {
queryBuilder.andWhere('period.status = :status', { status: filters.status });
}
if (filters.date_from) {
queryBuilder.andWhere('period.starts_at >= :dateFrom', { dateFrom: filters.date_from });
}
if (filters.date_to) {
queryBuilder.andWhere('period.ends_at <= :dateTo', { dateTo: filters.date_to });
}
const [data, total] = await queryBuilder
.orderBy(`period.${sortBy}`, sortOrder)
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, id: string): Promise<CommissionPeriod> {
const period = await this.periodRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['creator', 'closedByUser', 'paidByUser'],
});
if (!period) {
throw new NotFoundException(`Commission period with ID ${id} not found`);
}
return period;
}
async getOpenPeriod(tenantId: string): Promise<CommissionPeriod | null> {
return this.periodRepository.findOne({
where: { tenant_id: tenantId, status: PeriodStatus.OPEN },
order: { starts_at: 'DESC' },
});
}
async create(tenantId: string, dto: CreatePeriodDto, createdBy?: string): Promise<CommissionPeriod> {
// Check for overlapping periods
const overlapping = await this.periodRepository
.createQueryBuilder('period')
.where('period.tenant_id = :tenantId', { tenantId })
.andWhere(
'(period.starts_at < :endsAt AND period.ends_at > :startsAt)',
{ startsAt: dto.starts_at, endsAt: dto.ends_at },
)
.getOne();
if (overlapping) {
throw new BadRequestException('Period dates overlap with an existing period');
}
const period = this.periodRepository.create({
...dto,
tenant_id: tenantId,
created_by: createdBy,
status: PeriodStatus.OPEN,
});
return this.periodRepository.save(period);
}
async close(tenantId: string, id: string, closedBy: string): Promise<CommissionPeriod> {
const period = await this.findOne(tenantId, id);
if (!period.isCloseable) {
throw new BadRequestException('Period cannot be closed. Current status: ' + period.status);
}
// Calculate totals
const stats = await this.entryRepository
.createQueryBuilder('entry')
.select('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.where('entry.period_id = :periodId', { periodId: id })
.andWhere('entry.status IN (:...statuses)', { statuses: [EntryStatus.PENDING, EntryStatus.APPROVED] })
.getRawOne();
period.status = PeriodStatus.CLOSED;
period.closed_at = new Date();
period.closed_by = closedBy;
period.total_entries = parseInt(stats.count) || 0;
period.total_amount = parseFloat(stats.total) || 0;
return this.periodRepository.save(period);
}
async reopen(tenantId: string, id: string): Promise<CommissionPeriod> {
const period = await this.findOne(tenantId, id);
if (period.status !== PeriodStatus.CLOSED) {
throw new BadRequestException('Only closed periods can be reopened');
}
period.status = PeriodStatus.OPEN;
period.closed_at = null;
period.closed_by = null;
return this.periodRepository.save(period);
}
async markAsProcessing(tenantId: string, id: string): Promise<CommissionPeriod> {
const period = await this.findOne(tenantId, id);
if (period.status !== PeriodStatus.CLOSED) {
throw new BadRequestException('Only closed periods can be marked as processing');
}
period.status = PeriodStatus.PROCESSING;
return this.periodRepository.save(period);
}
async markAsPaid(
tenantId: string,
id: string,
paidBy: string,
paymentReference?: string,
paymentNotes?: string,
): Promise<CommissionPeriod> {
const period = await this.findOne(tenantId, id);
if (!period.isPayable) {
throw new BadRequestException('Period is not ready for payment. Current status: ' + period.status);
}
// Mark all approved entries as paid
await this.entryRepository
.createQueryBuilder()
.update(CommissionEntry)
.set({
status: EntryStatus.PAID,
paid_at: new Date(),
payment_reference: paymentReference,
})
.where('period_id = :periodId', { periodId: id })
.andWhere('status = :status', { status: EntryStatus.APPROVED })
.execute();
period.status = PeriodStatus.PAID;
period.paid_at = new Date();
period.paid_by = paidBy;
period.payment_reference = paymentReference || null;
period.payment_notes = paymentNotes || null;
return this.periodRepository.save(period);
}
async getSummary(tenantId: string, id: string): Promise<PeriodSummary> {
await this.findOne(tenantId, id); // Verify period exists
// Get entries by status
const byStatus = await this.entryRepository
.createQueryBuilder('entry')
.select('entry.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total')
.where('entry.period_id = :periodId', { periodId: id })
.groupBy('entry.status')
.getRawMany();
// Get entries by user
const byUser = await this.entryRepository
.createQueryBuilder('entry')
.leftJoin('entry.user', 'user')
.select('entry.user_id', 'user_id')
.addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name')
.addSelect('COUNT(*)', 'entries_count')
.addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total_amount')
.where('entry.period_id = :periodId', { periodId: id })
.andWhere('entry.status NOT IN (:...excludedStatuses)', { excludedStatuses: [EntryStatus.REJECTED, EntryStatus.CANCELLED] })
.groupBy('entry.user_id')
.addGroupBy('user.first_name')
.addGroupBy('user.last_name')
.orderBy('total_amount', 'DESC')
.getRawMany();
const statusMap: PeriodSummary['by_status'] = {
pending: 0,
approved: 0,
rejected: 0,
paid: 0,
cancelled: 0,
};
let totalEntries = 0;
let totalAmount = 0;
byStatus.forEach((row) => {
const count = parseInt(row.count);
const total = parseFloat(row.total);
statusMap[row.status as keyof typeof statusMap] = count;
if (row.status !== EntryStatus.REJECTED && row.status !== EntryStatus.CANCELLED) {
totalEntries += count;
totalAmount += total;
}
});
return {
total_entries: totalEntries,
total_amount: totalAmount,
by_status: statusMap,
by_user: byUser.map((row) => ({
user_id: row.user_id,
user_name: row.user_name || 'Unknown',
entries_count: parseInt(row.entries_count),
total_amount: parseFloat(row.total_amount),
})),
};
}
}

View File

@ -0,0 +1,200 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CommissionScheme, SchemeType, TierConfig } from '../entities/commission-scheme.entity';
import { CreateSchemeDto, UpdateSchemeDto } from '../dto';
export interface SchemeFilters {
type?: SchemeType;
is_active?: boolean;
search?: string;
}
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class SchemesService {
constructor(
@InjectRepository(CommissionScheme)
private readonly schemeRepository: Repository<CommissionScheme>,
) {}
async findAll(
tenantId: string,
filters: SchemeFilters = {},
pagination: PaginationOptions = {},
): Promise<PaginatedResult<CommissionScheme>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination;
const skip = (page - 1) * limit;
const queryBuilder = this.schemeRepository.createQueryBuilder('scheme')
.where('scheme.tenant_id = :tenantId', { tenantId })
.andWhere('scheme.deleted_at IS NULL');
if (filters.type) {
queryBuilder.andWhere('scheme.type = :type', { type: filters.type });
}
if (filters.is_active !== undefined) {
queryBuilder.andWhere('scheme.is_active = :isActive', { isActive: filters.is_active });
}
if (filters.search) {
queryBuilder.andWhere(
'(scheme.name ILIKE :search OR scheme.description ILIKE :search)',
{ search: `%${filters.search}%` },
);
}
const [data, total] = await queryBuilder
.orderBy(`scheme.${sortBy}`, sortOrder)
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(tenantId: string, id: string): Promise<CommissionScheme> {
const scheme = await this.schemeRepository.findOne({
where: { id, tenant_id: tenantId, deleted_at: undefined },
relations: ['creator', 'assignments'],
});
if (!scheme) {
throw new NotFoundException(`Commission scheme with ID ${id} not found`);
}
return scheme;
}
async findActive(tenantId: string): Promise<CommissionScheme[]> {
return this.schemeRepository.find({
where: {
tenant_id: tenantId,
is_active: true,
deleted_at: undefined,
},
order: { name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateSchemeDto, createdBy?: string): Promise<CommissionScheme> {
// Validate tiers if tiered type
if (dto.type === SchemeType.TIERED) {
this.validateTiers(dto.tiers || []);
}
const scheme = this.schemeRepository.create({
...dto,
tenant_id: tenantId,
created_by: createdBy,
});
return this.schemeRepository.save(scheme);
}
async update(tenantId: string, id: string, dto: UpdateSchemeDto): Promise<CommissionScheme> {
const scheme = await this.findOne(tenantId, id);
// Validate tiers if updating to tiered type or updating tiers
if (dto.type === SchemeType.TIERED || (scheme.type === SchemeType.TIERED && dto.tiers)) {
this.validateTiers(dto.tiers || scheme.tiers);
}
Object.assign(scheme, dto);
return this.schemeRepository.save(scheme);
}
async remove(tenantId: string, id: string): Promise<void> {
const scheme = await this.findOne(tenantId, id);
scheme.deleted_at = new Date();
scheme.is_active = false;
await this.schemeRepository.save(scheme);
}
async duplicate(tenantId: string, id: string, newName?: string, createdBy?: string): Promise<CommissionScheme> {
const original = await this.findOne(tenantId, id);
const duplicate = this.schemeRepository.create({
tenant_id: tenantId,
name: newName || `${original.name} (Copy)`,
description: original.description,
type: original.type,
rate: original.rate,
fixed_amount: original.fixed_amount,
tiers: original.tiers,
applies_to: original.applies_to,
product_ids: original.product_ids,
category_ids: original.category_ids,
min_amount: original.min_amount,
max_amount: original.max_amount,
is_active: false, // Start as inactive
created_by: createdBy,
});
return this.schemeRepository.save(duplicate);
}
async toggleActive(tenantId: string, id: string): Promise<CommissionScheme> {
const scheme = await this.findOne(tenantId, id);
scheme.is_active = !scheme.is_active;
return this.schemeRepository.save(scheme);
}
validateTiers(tiers: TierConfig[]): void {
if (!Array.isArray(tiers) || tiers.length === 0) {
throw new BadRequestException('Tiered schemes require at least one tier configuration');
}
// Sort tiers by from amount
const sortedTiers = [...tiers].sort((a, b) => a.from - b.from);
for (let i = 0; i < sortedTiers.length; i++) {
const tier = sortedTiers[i];
// Validate tier structure
if (typeof tier.from !== 'number' || tier.from < 0) {
throw new BadRequestException(`Invalid tier 'from' value at index ${i}`);
}
if (tier.to !== null && (typeof tier.to !== 'number' || tier.to <= tier.from)) {
throw new BadRequestException(`Invalid tier 'to' value at index ${i}: must be greater than 'from'`);
}
if (typeof tier.rate !== 'number' || tier.rate < 0 || tier.rate > 100) {
throw new BadRequestException(`Invalid tier rate at index ${i}: must be between 0 and 100`);
}
// Check for gaps or overlaps with next tier
if (i < sortedTiers.length - 1) {
const nextTier = sortedTiers[i + 1];
if (tier.to === null) {
throw new BadRequestException('Only the last tier can have an unlimited upper bound');
}
if (tier.to !== nextTier.from) {
throw new BadRequestException(`Gap or overlap between tiers at index ${i} and ${i + 1}`);
}
}
}
}
}

View File

@ -0,0 +1,134 @@
import { DollarSign, Users, Calendar, TrendingUp, Clock, CheckCircle } from 'lucide-react';
import { DashboardSummary, TopEarner, PeriodEarnings } from '../../services/commissions/dashboard.api';
interface CommissionsDashboardProps {
summary: DashboardSummary;
topEarners: TopEarner[];
periodEarnings: PeriodEarnings[];
}
export function CommissionsDashboard({ summary, topEarners, periodEarnings }: CommissionsDashboardProps) {
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: summary.currency }).format(amount);
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Pending Commissions</p>
<p className="text-2xl font-bold text-yellow-600">{formatCurrency(summary.total_pending)}</p>
<p className="text-xs text-gray-400">{summary.pending_count} entries</p>
</div>
<div className="p-3 bg-yellow-100 rounded-full">
<Clock className="h-6 w-6 text-yellow-600" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Approved</p>
<p className="text-2xl font-bold text-green-600">{formatCurrency(summary.total_approved)}</p>
<p className="text-xs text-gray-400">{summary.approved_count} entries</p>
</div>
<div className="p-3 bg-green-100 rounded-full">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Total Paid</p>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(summary.total_paid)}</p>
<p className="text-xs text-gray-400">{summary.paid_count} entries</p>
</div>
<div className="p-3 bg-blue-100 rounded-full">
<DollarSign className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Active Schemes</p>
<p className="text-2xl font-bold text-purple-600">{summary.active_schemes}</p>
<p className="text-xs text-gray-400">{summary.active_assignments} assignments</p>
</div>
<div className="p-3 bg-purple-100 rounded-full">
<TrendingUp className="h-6 w-6 text-purple-600" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Earners */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Top Earners</h3>
{topEarners.length === 0 ? (
<p className="text-gray-500 text-center py-4">No earners data</p>
) : (
<div className="space-y-3">
{topEarners.map((earner) => (
<div key={earner.user_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold">
{earner.rank}
</div>
<div>
<p className="font-medium">{earner.user_name}</p>
<p className="text-sm text-gray-500">{earner.entries_count} sales</p>
</div>
</div>
<p className="font-bold text-green-600">{formatCurrency(earner.total_earned)}</p>
</div>
))}
</div>
)}
</div>
{/* Recent Periods */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Recent Periods</h3>
{periodEarnings.length === 0 ? (
<p className="text-gray-500 text-center py-4">No periods data</p>
) : (
<div className="space-y-3">
{periodEarnings.slice(0, 5).map((period) => (
<div key={period.period_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium">{period.period_name}</p>
<p className="text-sm text-gray-500">
{new Date(period.starts_at).toLocaleDateString()} - {new Date(period.ends_at).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="font-bold">{formatCurrency(period.total_amount)}</p>
<span
className={`text-xs px-2 py-1 rounded-full ${
period.status === 'paid'
? 'bg-green-100 text-green-700'
: period.status === 'open'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{period.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { ReactNode } from 'react';
interface EarningsCardProps {
title: string;
value: string;
icon: ReactNode;
color: 'blue' | 'green' | 'yellow' | 'purple' | 'red';
subtitle?: string;
}
export function EarningsCard({ title, value, icon, color, subtitle }: EarningsCardProps) {
const colorClasses = {
blue: 'bg-blue-100 text-blue-600',
green: 'bg-green-100 text-green-600',
yellow: 'bg-yellow-100 text-yellow-600',
purple: 'bg-purple-100 text-purple-600',
red: 'bg-red-100 text-red-600',
};
const valueColors = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
purple: 'text-purple-600',
red: 'text-red-600',
};
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className={`text-2xl font-bold ${valueColors[color]}`}>{value}</p>
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
</div>
<div className={`p-3 rounded-full ${colorClasses[color]}`}>
{icon}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { CommissionEntry } from '../../services/commissions/entries.api';
import { EntryStatusBadge } from './EntryStatusBadge';
interface EntriesListProps {
entries: CommissionEntry[];
total: number;
page: number;
totalPages: number;
selectedEntries: string[];
onSelectionChange: (ids: string[]) => void;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function EntriesList({
entries,
total,
page,
totalPages,
selectedEntries,
onSelectionChange,
onPageChange,
}: EntriesListProps) {
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
const handleSelectAll = () => {
const pendingIds = entries.filter((e) => e.status === 'pending').map((e) => e.id);
if (pendingIds.every((id) => selectedEntries.includes(id))) {
onSelectionChange(selectedEntries.filter((id) => !pendingIds.includes(id)));
} else {
onSelectionChange([...new Set([...selectedEntries, ...pendingIds])]);
}
};
const handleSelectOne = (id: string) => {
if (selectedEntries.includes(id)) {
onSelectionChange(selectedEntries.filter((i) => i !== id));
} else {
onSelectionChange([...selectedEntries, id]);
}
};
const pendingEntries = entries.filter((e) => e.status === 'pending');
const allPendingSelected = pendingEntries.length > 0 && pendingEntries.every((e) => selectedEntries.includes(e.id));
return (
<div className="bg-white rounded-lg shadow">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-gray-50">
<th className="py-3 px-4 w-10">
<input
type="checkbox"
checked={allPendingSelected}
onChange={handleSelectAll}
className="h-4 w-4 text-blue-600 rounded"
title="Select all pending"
/>
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Date</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Reference</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Base Amount</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Rate</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Commission</th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-gray-500">
No commission entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 px-4">
{entry.status === 'pending' && (
<input
type="checkbox"
checked={selectedEntries.includes(entry.id)}
onChange={() => handleSelectOne(entry.id)}
className="h-4 w-4 text-blue-600 rounded"
/>
)}
</td>
<td className="py-3 px-4 text-sm">
{new Date(entry.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-sm">
{entry.user
? `${entry.user.first_name} ${entry.user.last_name}`
: entry.user_id.slice(0, 8)}
</td>
<td className="py-3 px-4 text-sm capitalize">
{entry.reference_type}
</td>
<td className="py-3 px-4 text-sm text-right">
{formatCurrency(entry.base_amount)}
</td>
<td className="py-3 px-4 text-sm text-right">
{entry.rate_applied}%
</td>
<td className="py-3 px-4 text-sm text-right font-medium text-green-600">
{formatCurrency(entry.commission_amount)}
</td>
<td className="py-3 px-4 text-center">
<EntryStatusBadge status={entry.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-gray-500">
Showing {entries.length} of {total} entries
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { EntryStatus } from '../../services/commissions/entries.api';
interface EntryStatusBadgeProps {
status: EntryStatus;
}
export function EntryStatusBadge({ status }: EntryStatusBadgeProps) {
const getStatusConfig = (status: EntryStatus) => {
switch (status) {
case 'pending':
return { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Pending' };
case 'approved':
return { bg: 'bg-green-100', text: 'text-green-700', label: 'Approved' };
case 'rejected':
return { bg: 'bg-red-100', text: 'text-red-700', label: 'Rejected' };
case 'paid':
return { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Paid' };
case 'cancelled':
return { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Cancelled' };
default:
return { bg: 'bg-gray-100', text: 'text-gray-700', label: status };
}
};
const config = getStatusConfig(status);
return (
<span className={`px-2 py-1 text-xs rounded-full ${config.bg} ${config.text}`}>
{config.label}
</span>
);
}

View File

@ -0,0 +1,205 @@
import { ChevronLeft, ChevronRight, Lock, Unlock, DollarSign, Clock, CheckCircle } from 'lucide-react';
import { CommissionPeriod, PeriodStatus } from '../../services/commissions/periods.api';
import { useClosePeriod, useReopenPeriod, useMarkPeriodPaid } from '../../hooks/commissions';
interface PeriodManagerProps {
periods: CommissionPeriod[];
total: number;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function PeriodManager({
periods,
total,
page,
totalPages,
onPageChange,
onRefresh,
}: PeriodManagerProps) {
const closeMutation = useClosePeriod();
const reopenMutation = useReopenPeriod();
const payMutation = useMarkPeriodPaid();
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
const handleClose = async (id: string) => {
if (window.confirm('Close this period? This will finalize the totals.')) {
await closeMutation.mutateAsync(id);
onRefresh();
}
};
const handleReopen = async (id: string) => {
if (window.confirm('Reopen this period?')) {
await reopenMutation.mutateAsync(id);
onRefresh();
}
};
const handlePay = async (id: string) => {
const reference = window.prompt('Enter payment reference (optional):');
await payMutation.mutateAsync({ id, data: reference ? { payment_reference: reference } : undefined });
onRefresh();
};
const getStatusIcon = (status: PeriodStatus) => {
switch (status) {
case 'open':
return <Unlock className="h-4 w-4 text-blue-500" />;
case 'closed':
return <Lock className="h-4 w-4 text-orange-500" />;
case 'processing':
return <Clock className="h-4 w-4 text-yellow-500" />;
case 'paid':
return <CheckCircle className="h-4 w-4 text-green-500" />;
default:
return null;
}
};
const getStatusBadge = (status: PeriodStatus) => {
const configs: Record<PeriodStatus, { bg: string; text: string }> = {
open: { bg: 'bg-blue-100', text: 'text-blue-700' },
closed: { bg: 'bg-orange-100', text: 'text-orange-700' },
processing: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
paid: { bg: 'bg-green-100', text: 'text-green-700' },
};
const config = configs[status];
return (
<span className={`px-2 py-1 text-xs rounded-full ${config.bg} ${config.text} capitalize`}>
{status}
</span>
);
};
return (
<div className="bg-white rounded-lg shadow">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Period</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Date Range</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Entries</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Total Amount</th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500">Status</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody>
{periods.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
No commission periods found
</td>
</tr>
) : (
periods.map((period) => (
<tr key={period.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{getStatusIcon(period.status)}
<span className="font-medium">{period.name}</span>
</div>
</td>
<td className="py-3 px-4 text-sm">
{new Date(period.starts_at).toLocaleDateString()} -{' '}
{new Date(period.ends_at).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-sm text-right">
{period.total_entries}
</td>
<td className="py-3 px-4 text-sm text-right font-medium">
{formatCurrency(period.total_amount)}
</td>
<td className="py-3 px-4 text-center">
{getStatusBadge(period.status)}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-2">
{period.status === 'open' && (
<button
onClick={() => handleClose(period.id)}
disabled={closeMutation.isPending}
className="px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200"
>
Close
</button>
)}
{period.status === 'closed' && (
<>
<button
onClick={() => handleReopen(period.id)}
disabled={reopenMutation.isPending}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Reopen
</button>
<button
onClick={() => handlePay(period.id)}
disabled={payMutation.isPending}
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200 flex items-center gap-1"
>
<DollarSign className="h-4 w-4" />
Pay
</button>
</>
)}
{period.status === 'processing' && (
<button
onClick={() => handlePay(period.id)}
disabled={payMutation.isPending}
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200 flex items-center gap-1"
>
<DollarSign className="h-4 w-4" />
Mark Paid
</button>
)}
{period.status === 'paid' && period.paid_at && (
<span className="text-xs text-gray-500">
Paid {new Date(period.paid_at).toLocaleDateString()}
</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-gray-500">
Showing {periods.length} of {total} periods
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,266 @@
import { useState } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { useCreateScheme, useUpdateScheme } from '../../hooks/commissions';
import { CreateSchemeDto, SchemeType, AppliesTo, TierConfig, CommissionScheme } from '../../services/commissions/schemes.api';
interface SchemeFormProps {
scheme?: CommissionScheme;
onClose: () => void;
onSuccess: () => void;
}
export function SchemeForm({ scheme, onClose, onSuccess }: SchemeFormProps) {
const isEditing = !!scheme;
const createMutation = useCreateScheme();
const updateMutation = useUpdateScheme();
const [formData, setFormData] = useState<CreateSchemeDto>({
name: scheme?.name || '',
description: scheme?.description || '',
type: scheme?.type || 'percentage',
rate: scheme?.rate || 10,
fixed_amount: scheme?.fixed_amount || 0,
tiers: scheme?.tiers || [{ from: 0, to: 1000, rate: 5 }],
applies_to: scheme?.applies_to || 'all',
min_amount: scheme?.min_amount || 0,
max_amount: scheme?.max_amount || undefined,
is_active: scheme?.is_active ?? true,
});
const [tiers, setTiers] = useState<TierConfig[]>(formData.tiers || []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data: CreateSchemeDto = {
...formData,
tiers: formData.type === 'tiered' ? tiers : [],
};
if (isEditing) {
await updateMutation.mutateAsync({ id: scheme.id, data });
} else {
await createMutation.mutateAsync(data);
}
onSuccess();
};
const addTier = () => {
const lastTier = tiers[tiers.length - 1];
const newFrom = lastTier?.to || 0;
setTiers([...tiers, { from: newFrom, to: newFrom + 1000, rate: 5 }]);
};
const removeTier = (index: number) => {
setTiers(tiers.filter((_, i) => i !== index));
};
const updateTier = (index: number, field: keyof TierConfig, value: number | null) => {
setTiers(tiers.map((tier, i) => (i === index ? { ...tier, [field]: value } : tier)));
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-white">
<h2 className="text-xl font-bold">
{isEditing ? 'Edit Scheme' : 'Create Commission Scheme'}
</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type *</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as SchemeType })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option>
<option value="tiered">Tiered</option>
</select>
</div>
{formData.type === 'percentage' && (
<div>
<label className="block text-sm font-medium text-gray-700">Rate (%)</label>
<input
type="number"
min="0"
max="100"
step="0.01"
value={formData.rate}
onChange={(e) => setFormData({ ...formData, rate: parseFloat(e.target.value) })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
)}
{formData.type === 'fixed' && (
<div>
<label className="block text-sm font-medium text-gray-700">Fixed Amount ($)</label>
<input
type="number"
min="0"
step="0.01"
value={formData.fixed_amount}
onChange={(e) => setFormData({ ...formData, fixed_amount: parseFloat(e.target.value) })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
)}
{formData.type === 'tiered' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Tiers</label>
<div className="space-y-2">
{tiers.map((tier, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<input
type="number"
min="0"
value={tier.from}
onChange={(e) => updateTier(index, 'from', parseFloat(e.target.value))}
className="w-20 px-2 py-1 border rounded text-sm"
placeholder="From"
/>
<span className="text-gray-500">-</span>
<input
type="number"
min="0"
value={tier.to || ''}
onChange={(e) => updateTier(index, 'to', e.target.value ? parseFloat(e.target.value) : null)}
className="w-20 px-2 py-1 border rounded text-sm"
placeholder="To"
/>
<span className="text-gray-500">@</span>
<input
type="number"
min="0"
max="100"
step="0.01"
value={tier.rate}
onChange={(e) => updateTier(index, 'rate', parseFloat(e.target.value))}
className="w-16 px-2 py-1 border rounded text-sm"
/>
<span className="text-gray-500">%</span>
<button
type="button"
onClick={() => removeTier(index)}
className="p-1 text-red-500 hover:bg-red-50 rounded"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
onClick={addTier}
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
>
<Plus className="h-4 w-4" /> Add Tier
</button>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Applies To</label>
<select
value={formData.applies_to}
onChange={(e) => setFormData({ ...formData, applies_to: e.target.value as AppliesTo })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="all">All Sales</option>
<option value="products">Specific Products</option>
<option value="categories">Specific Categories</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Min Amount</label>
<input
type="number"
min="0"
value={formData.min_amount}
onChange={(e) => setFormData({ ...formData, min_amount: parseFloat(e.target.value) })}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max Commission (Cap)</label>
<input
type="number"
min="0"
value={formData.max_amount || ''}
onChange={(e) =>
setFormData({ ...formData, max_amount: e.target.value ? parseFloat(e.target.value) : undefined })
}
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="No cap"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="h-4 w-4 text-blue-600 rounded"
/>
<label htmlFor="is_active" className="text-sm text-gray-700">
Active
</label>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,185 @@
import { Pencil, Trash2, Copy, ToggleLeft, ToggleRight, ChevronLeft, ChevronRight } from 'lucide-react';
import { CommissionScheme } from '../../services/commissions/schemes.api';
import { useDeleteScheme, useDuplicateScheme, useToggleSchemeActive } from '../../hooks/commissions';
interface SchemesListProps {
schemes: CommissionScheme[];
total: number;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function SchemesList({
schemes,
total,
page,
totalPages,
onPageChange,
onRefresh,
}: SchemesListProps) {
const deleteMutation = useDeleteScheme();
const duplicateMutation = useDuplicateScheme();
const toggleActiveMutation = useToggleSchemeActive();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this scheme?')) {
await deleteMutation.mutateAsync(id);
onRefresh();
}
};
const handleDuplicate = async (id: string) => {
await duplicateMutation.mutateAsync({ id });
onRefresh();
};
const handleToggleActive = async (id: string) => {
await toggleActiveMutation.mutateAsync(id);
onRefresh();
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'percentage':
return 'Percentage';
case 'fixed':
return 'Fixed';
case 'tiered':
return 'Tiered';
default:
return type;
}
};
const formatRate = (scheme: CommissionScheme) => {
if (scheme.type === 'percentage') {
return `${scheme.rate}%`;
}
if (scheme.type === 'fixed') {
return `$${scheme.fixed_amount}`;
}
return `${scheme.tiers.length} tiers`;
};
return (
<div className="bg-white rounded-lg shadow">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Type</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Rate/Amount</th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500">Applies To</th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500">Status</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody>
{schemes.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
No commission schemes found
</td>
</tr>
) : (
schemes.map((scheme) => (
<tr key={scheme.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium text-gray-900">{scheme.name}</p>
{scheme.description && (
<p className="text-sm text-gray-500 truncate max-w-xs">{scheme.description}</p>
)}
</div>
</td>
<td className="py-3 px-4">
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700">
{getTypeLabel(scheme.type)}
</span>
</td>
<td className="py-3 px-4 text-right font-medium">
{formatRate(scheme)}
</td>
<td className="py-3 px-4 text-center capitalize">
{scheme.applies_to}
</td>
<td className="py-3 px-4 text-center">
<span
className={`px-2 py-1 text-xs rounded-full ${
scheme.is_active
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{scheme.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggleActive(scheme.id)}
className="p-1 text-gray-500 hover:text-blue-600"
title={scheme.is_active ? 'Deactivate' : 'Activate'}
>
{scheme.is_active ? (
<ToggleRight className="h-4 w-4" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
</button>
<button
onClick={() => handleDuplicate(scheme.id)}
className="p-1 text-gray-500 hover:text-blue-600"
title="Duplicate"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(scheme.id)}
className="p-1 text-gray-500 hover:text-red-600"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-gray-500">
Showing {schemes.length} of {total} schemes
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
export * from './CommissionsDashboard';
export * from './SchemesList';
export * from './SchemeForm';
export * from './EntriesList';
export * from './EntryStatusBadge';
export * from './PeriodManager';
export * from './EarningsCard';

View File

@ -0,0 +1,5 @@
export * from './useSchemes';
export * from './useAssignments';
export * from './useEntries';
export * from './usePeriods';
export * from './useCommissionsDashboard';

View File

@ -0,0 +1,112 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
assignmentsApi,
CreateAssignmentDto,
UpdateAssignmentDto,
AssignmentFilters,
} from '../../services/commissions/assignments.api';
const QUERY_KEYS = {
assignments: ['commissions', 'assignments'] as const,
assignment: (id: string) => ['commissions', 'assignments', id] as const,
userAssignments: (userId: string) => ['commissions', 'assignments', 'user', userId] as const,
userActiveScheme: (userId: string) => ['commissions', 'assignments', 'user', userId, 'active'] as const,
schemeAssignees: (schemeId: string) => ['commissions', 'assignments', 'scheme', schemeId] as const,
};
export function useAssignments(filters?: AssignmentFilters) {
return useQuery({
queryKey: [...QUERY_KEYS.assignments, filters],
queryFn: () => assignmentsApi.list(filters),
staleTime: 30 * 1000,
});
}
export function useAssignment(id: string) {
return useQuery({
queryKey: QUERY_KEYS.assignment(id),
queryFn: () => assignmentsApi.get(id),
enabled: !!id,
});
}
export function useUserAssignments(userId: string) {
return useQuery({
queryKey: QUERY_KEYS.userAssignments(userId),
queryFn: () => assignmentsApi.getByUser(userId),
enabled: !!userId,
staleTime: 30 * 1000,
});
}
export function useUserActiveScheme(userId: string) {
return useQuery({
queryKey: QUERY_KEYS.userActiveScheme(userId),
queryFn: () => assignmentsApi.getActiveScheme(userId),
enabled: !!userId,
staleTime: 60 * 1000,
});
}
export function useSchemeAssignees(schemeId: string) {
return useQuery({
queryKey: QUERY_KEYS.schemeAssignees(schemeId),
queryFn: () => assignmentsApi.getSchemeAssignees(schemeId),
enabled: !!schemeId,
staleTime: 30 * 1000,
});
}
export function useAssign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAssignmentDto) => assignmentsApi.assign(data),
onSuccess: (assignment) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.assignments });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userAssignments(assignment.user_id) });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userActiveScheme(assignment.user_id) });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemeAssignees(assignment.scheme_id) });
},
});
}
export function useUpdateAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateAssignmentDto }) =>
assignmentsApi.update(id, data),
onSuccess: (updatedAssignment) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.assignments });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userAssignments(updatedAssignment.user_id) });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userActiveScheme(updatedAssignment.user_id) });
queryClient.setQueryData(QUERY_KEYS.assignment(updatedAssignment.id), updatedAssignment);
},
});
}
export function useRemoveAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => assignmentsApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.assignments });
},
});
}
export function useDeactivateAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => assignmentsApi.deactivate(id),
onSuccess: (deactivatedAssignment) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.assignments });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userAssignments(deactivatedAssignment.user_id) });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userActiveScheme(deactivatedAssignment.user_id) });
queryClient.setQueryData(QUERY_KEYS.assignment(deactivatedAssignment.id), deactivatedAssignment);
},
});
}

View File

@ -0,0 +1,70 @@
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '../../services/commissions/dashboard.api';
const QUERY_KEYS = {
summary: ['commissions', 'dashboard', 'summary'] as const,
earningsByUser: ['commissions', 'dashboard', 'by-user'] as const,
earningsByPeriod: ['commissions', 'dashboard', 'by-period'] as const,
topEarners: ['commissions', 'dashboard', 'top-earners'] as const,
myEarnings: ['commissions', 'dashboard', 'my-earnings'] as const,
userEarnings: (userId: string) => ['commissions', 'dashboard', 'user', userId, 'earnings'] as const,
schemePerformance: (schemeId: string) => ['commissions', 'dashboard', 'scheme', schemeId, 'performance'] as const,
};
export function useCommissionsSummary() {
return useQuery({
queryKey: QUERY_KEYS.summary,
queryFn: dashboardApi.getSummary,
staleTime: 30 * 1000,
});
}
export function useEarningsByUser(params?: { date_from?: string; date_to?: string }) {
return useQuery({
queryKey: [...QUERY_KEYS.earningsByUser, params],
queryFn: () => dashboardApi.getEarningsByUser(params),
staleTime: 60 * 1000,
});
}
export function useEarningsByPeriod() {
return useQuery({
queryKey: QUERY_KEYS.earningsByPeriod,
queryFn: dashboardApi.getEarningsByPeriod,
staleTime: 60 * 1000,
});
}
export function useTopEarners(params?: { limit?: number; date_from?: string; date_to?: string }) {
return useQuery({
queryKey: [...QUERY_KEYS.topEarners, params],
queryFn: () => dashboardApi.getTopEarners(params),
staleTime: 60 * 1000,
});
}
export function useMyEarnings() {
return useQuery({
queryKey: QUERY_KEYS.myEarnings,
queryFn: dashboardApi.getMyEarnings,
staleTime: 30 * 1000,
});
}
export function useUserEarnings(userId: string) {
return useQuery({
queryKey: QUERY_KEYS.userEarnings(userId),
queryFn: () => dashboardApi.getUserEarnings(userId),
enabled: !!userId,
staleTime: 30 * 1000,
});
}
export function useSchemePerformance(schemeId: string) {
return useQuery({
queryKey: QUERY_KEYS.schemePerformance(schemeId),
queryFn: () => dashboardApi.getSchemePerformance(schemeId),
enabled: !!schemeId,
staleTime: 60 * 1000,
});
}

View File

@ -0,0 +1,135 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
entriesApi,
CalculateCommissionDto,
UpdateEntryStatusDto,
EntryFilters,
} from '../../services/commissions/entries.api';
const QUERY_KEYS = {
entries: ['commissions', 'entries'] as const,
entry: (id: string) => ['commissions', 'entries', id] as const,
pendingEntries: ['commissions', 'entries', 'pending'] as const,
userEntries: (userId: string) => ['commissions', 'entries', 'user', userId] as const,
periodEntries: (periodId: string) => ['commissions', 'entries', 'period', periodId] as const,
referenceEntry: (type: string, refId: string) => ['commissions', 'entries', 'reference', type, refId] as const,
};
export function useEntries(filters?: EntryFilters) {
return useQuery({
queryKey: [...QUERY_KEYS.entries, filters],
queryFn: () => entriesApi.list(filters),
staleTime: 30 * 1000,
});
}
export function usePendingEntries(params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: [...QUERY_KEYS.pendingEntries, params],
queryFn: () => entriesApi.listPending(params),
staleTime: 15 * 1000,
});
}
export function useEntry(id: string) {
return useQuery({
queryKey: QUERY_KEYS.entry(id),
queryFn: () => entriesApi.get(id),
enabled: !!id,
});
}
export function useUserEntries(userId: string, params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: [...QUERY_KEYS.userEntries(userId), params],
queryFn: () => entriesApi.getByUser(userId, params),
enabled: !!userId,
staleTime: 30 * 1000,
});
}
export function usePeriodEntries(periodId: string, params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: [...QUERY_KEYS.periodEntries(periodId), params],
queryFn: () => entriesApi.getByPeriod(periodId, params),
enabled: !!periodId,
staleTime: 30 * 1000,
});
}
export function useReferenceEntry(type: string, refId: string) {
return useQuery({
queryKey: QUERY_KEYS.referenceEntry(type, refId),
queryFn: () => entriesApi.getByReference(type, refId),
enabled: !!type && !!refId,
});
}
export function useCalculateCommission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CalculateCommissionDto) => entriesApi.calculate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.entries });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pendingEntries });
},
});
}
export function useSimulateCommission() {
return useMutation({
mutationFn: (data: CalculateCommissionDto) => entriesApi.simulate(data),
});
}
export function useUpdateEntryStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEntryStatusDto }) =>
entriesApi.updateStatus(id, data),
onSuccess: (updatedEntry) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.entries });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pendingEntries });
queryClient.setQueryData(QUERY_KEYS.entry(updatedEntry.id), updatedEntry);
},
});
}
export function useBulkApprove() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (entryIds: string[]) => entriesApi.bulkApprove(entryIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.entries });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pendingEntries });
},
});
}
export function useBulkReject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ entryIds, reason }: { entryIds: string[]; reason?: string }) =>
entriesApi.bulkReject(entryIds, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.entries });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pendingEntries });
},
});
}
export function useCancelByReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ type, refId, reason }: { type: string; refId: string; reason?: string }) =>
entriesApi.cancelByReference(type, refId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.entries });
},
});
}

View File

@ -0,0 +1,111 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
periodsApi,
CreatePeriodDto,
MarkPeriodPaidDto,
PeriodFilters,
} from '../../services/commissions/periods.api';
const QUERY_KEYS = {
periods: ['commissions', 'periods'] as const,
period: (id: string) => ['commissions', 'periods', id] as const,
periodSummary: (id: string) => ['commissions', 'periods', id, 'summary'] as const,
openPeriod: ['commissions', 'periods', 'open'] as const,
};
export function usePeriods(filters?: PeriodFilters) {
return useQuery({
queryKey: [...QUERY_KEYS.periods, filters],
queryFn: () => periodsApi.list(filters),
staleTime: 30 * 1000,
});
}
export function useOpenPeriod() {
return useQuery({
queryKey: QUERY_KEYS.openPeriod,
queryFn: periodsApi.getOpen,
staleTime: 60 * 1000,
});
}
export function usePeriod(id: string) {
return useQuery({
queryKey: QUERY_KEYS.period(id),
queryFn: () => periodsApi.get(id),
enabled: !!id,
});
}
export function usePeriodSummary(id: string) {
return useQuery({
queryKey: QUERY_KEYS.periodSummary(id),
queryFn: () => periodsApi.getSummary(id),
enabled: !!id,
staleTime: 30 * 1000,
});
}
export function useCreatePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePeriodDto) => periodsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.periods });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.openPeriod });
},
});
}
export function useClosePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => periodsApi.close(id),
onSuccess: (closedPeriod) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.periods });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.openPeriod });
queryClient.setQueryData(QUERY_KEYS.period(closedPeriod.id), closedPeriod);
},
});
}
export function useReopenPeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => periodsApi.reopen(id),
onSuccess: (reopenedPeriod) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.periods });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.openPeriod });
queryClient.setQueryData(QUERY_KEYS.period(reopenedPeriod.id), reopenedPeriod);
},
});
}
export function useMarkPeriodProcessing() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => periodsApi.markAsProcessing(id),
onSuccess: (updatedPeriod) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.periods });
queryClient.setQueryData(QUERY_KEYS.period(updatedPeriod.id), updatedPeriod);
},
});
}
export function useMarkPeriodPaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data?: MarkPeriodPaidDto }) =>
periodsApi.markAsPaid(id, data),
onSuccess: (paidPeriod) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.periods });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.openPeriod });
queryClient.setQueryData(QUERY_KEYS.period(paidPeriod.id), paidPeriod);
},
});
}

View File

@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
schemesApi,
CommissionScheme,
CreateSchemeDto,
UpdateSchemeDto,
SchemeFilters,
} from '../../services/commissions/schemes.api';
const QUERY_KEYS = {
schemes: ['commissions', 'schemes'] as const,
scheme: (id: string) => ['commissions', 'schemes', id] as const,
activeSchemes: ['commissions', 'schemes', 'active'] as const,
};
export function useSchemes(filters?: SchemeFilters) {
return useQuery({
queryKey: [...QUERY_KEYS.schemes, filters],
queryFn: () => schemesApi.list(filters),
staleTime: 30 * 1000,
});
}
export function useActiveSchemes() {
return useQuery({
queryKey: QUERY_KEYS.activeSchemes,
queryFn: schemesApi.listActive,
staleTime: 60 * 1000,
});
}
export function useScheme(id: string) {
return useQuery({
queryKey: QUERY_KEYS.scheme(id),
queryFn: () => schemesApi.get(id),
enabled: !!id,
});
}
export function useCreateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSchemeDto) => schemesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemes });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activeSchemes });
},
});
}
export function useUpdateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateSchemeDto }) =>
schemesApi.update(id, data),
onSuccess: (updatedScheme) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemes });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activeSchemes });
queryClient.setQueryData(QUERY_KEYS.scheme(updatedScheme.id), updatedScheme);
},
});
}
export function useDeleteScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => schemesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemes });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activeSchemes });
},
});
}
export function useDuplicateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: string; name?: string }) =>
schemesApi.duplicate(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemes });
},
});
}
export function useToggleSchemeActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => schemesApi.toggleActive(id),
onSuccess: (updatedScheme) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.schemes });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activeSchemes });
queryClient.setQueryData(QUERY_KEYS.scheme(updatedScheme.id), updatedScheme);
},
});
}

View File

@ -0,0 +1,128 @@
import { useState } from 'react';
import { useEntries, useBulkApprove, useBulkReject } from '../../../hooks/commissions';
import { EntriesList } from '../../../components/commissions/EntriesList';
import { EntryFilters, EntryStatus } from '../../../services/commissions/entries.api';
export default function EntriesPage() {
const [selectedEntries, setSelectedEntries] = useState<string[]>([]);
const [filters, setFilters] = useState<EntryFilters>({
page: 1,
limit: 20,
});
const { data, isLoading, refetch } = useEntries(filters);
const bulkApproveMutation = useBulkApprove();
const bulkRejectMutation = useBulkReject();
const handleFilterChange = (key: keyof EntryFilters, value: unknown) => {
setFilters((prev) => ({ ...prev, [key]: value, page: 1 }));
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
const handleBulkApprove = async () => {
if (selectedEntries.length === 0) return;
await bulkApproveMutation.mutateAsync(selectedEntries);
setSelectedEntries([]);
refetch();
};
const handleBulkReject = async () => {
if (selectedEntries.length === 0) return;
await bulkRejectMutation.mutateAsync({ entryIds: selectedEntries });
setSelectedEntries([]);
refetch();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Commission Entries</h1>
<p className="text-sm text-gray-500">
View and manage commission entries
</p>
</div>
{selectedEntries.length > 0 && (
<div className="flex gap-2">
<button
onClick={handleBulkApprove}
disabled={bulkApproveMutation.isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Approve ({selectedEntries.length})
</button>
<button
onClick={handleBulkReject}
disabled={bulkRejectMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
Reject ({selectedEntries.length})
</button>
</div>
)}
</div>
<div className="flex gap-4 items-center flex-wrap">
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="paid">Paid</option>
<option value="cancelled">Cancelled</option>
</select>
<select
value={filters.reference_type || ''}
onChange={(e) => handleFilterChange('reference_type', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Types</option>
<option value="sale">Sale</option>
<option value="opportunity">Opportunity</option>
<option value="order">Order</option>
</select>
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => handleFilterChange('date_from', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="From date"
/>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => handleFilterChange('date_to', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="To date"
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<EntriesList
entries={data?.data || []}
total={data?.total || 0}
page={data?.page || 1}
totalPages={data?.totalPages || 1}
selectedEntries={selectedEntries}
onSelectionChange={setSelectedEntries}
onPageChange={handlePageChange}
onRefresh={refetch}
/>
)}
</div>
);
}

View File

@ -0,0 +1,37 @@
import { useCommissionsSummary, useTopEarners, useEarningsByPeriod } from '../../hooks/commissions';
import { CommissionsDashboard } from '../../components/commissions/CommissionsDashboard';
export default function CommissionsPage() {
const { data: summary, isLoading: loadingSummary } = useCommissionsSummary();
const { data: topEarners, isLoading: loadingTopEarners } = useTopEarners({ limit: 5 });
const { data: periodEarnings, isLoading: loadingPeriods } = useEarningsByPeriod();
const isLoading = loadingSummary || loadingTopEarners || loadingPeriods;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Commissions Dashboard</h1>
<p className="text-sm text-gray-500">
Overview of commission schemes, earnings, and payouts
</p>
</div>
{summary && (
<CommissionsDashboard
summary={summary}
topEarners={topEarners || []}
periodEarnings={periodEarnings || []}
/>
)}
</div>
);
}

View File

@ -0,0 +1,125 @@
import { useMyEarnings } from '../../../hooks/commissions';
import { EarningsCard } from '../../../components/commissions/EarningsCard';
import { EntryStatusBadge } from '../../../components/commissions/EntryStatusBadge';
import { DollarSign, TrendingUp, Clock, CheckCircle } from 'lucide-react';
export default function MyEarningsPage() {
const { data: earnings, isLoading } = useMyEarnings();
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (!earnings) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No earnings data available</p>
</div>
);
}
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">My Earnings</h1>
<p className="text-sm text-gray-500">
Track your commission earnings and payouts
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<EarningsCard
title="Total Earnings"
value={formatCurrency(earnings.total)}
icon={<DollarSign className="h-5 w-5" />}
color="blue"
/>
<EarningsCard
title="Pending"
value={formatCurrency(earnings.pending)}
icon={<Clock className="h-5 w-5" />}
color="yellow"
subtitle={`Awaiting approval`}
/>
<EarningsCard
title="Approved"
value={formatCurrency(earnings.approved)}
icon={<CheckCircle className="h-5 w-5" />}
color="green"
subtitle="Ready for payment"
/>
<EarningsCard
title="Current Period"
value={formatCurrency(earnings.current_period_earnings)}
icon={<TrendingUp className="h-5 w-5" />}
color="purple"
/>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Recent Commissions</h2>
<span className="text-sm text-gray-500">
{earnings.entries_count} total entries
</span>
</div>
{earnings.recent_entries.length === 0 ? (
<p className="text-center text-gray-500 py-8">No commission entries yet</p>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Date</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Reference</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Base Amount</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Rate</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Commission</th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
{earnings.recent_entries.map((entry) => (
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 px-4 text-sm">
{new Date(entry.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-sm capitalize">
{entry.reference_type}
</td>
<td className="py-3 px-4 text-sm text-right">
{formatCurrency(entry.base_amount)}
</td>
<td className="py-3 px-4 text-sm text-right">
{entry.rate_applied}%
</td>
<td className="py-3 px-4 text-sm text-right font-medium">
{formatCurrency(entry.commission_amount)}
</td>
<td className="py-3 px-4 text-center">
<EntryStatusBadge status={entry.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{earnings.last_paid_date && (
<p className="mt-4 text-sm text-gray-500">
Last payment: {new Date(earnings.last_paid_date).toLocaleDateString()}
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { usePeriods, useCreatePeriod } from '../../../hooks/commissions';
import { PeriodManager } from '../../../components/commissions/PeriodManager';
import { PeriodFilters, CreatePeriodDto } from '../../../services/commissions/periods.api';
export default function PeriodsPage() {
const [showForm, setShowForm] = useState(false);
const [filters, setFilters] = useState<PeriodFilters>({
page: 1,
limit: 20,
sortBy: 'starts_at',
sortOrder: 'DESC',
});
const { data, isLoading, refetch } = usePeriods(filters);
const createPeriodMutation = useCreatePeriod();
const handleFilterChange = (key: keyof PeriodFilters, value: unknown) => {
setFilters((prev) => ({ ...prev, [key]: value, page: 1 }));
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
const handleCreatePeriod = async (data: CreatePeriodDto) => {
await createPeriodMutation.mutateAsync(data);
setShowForm(false);
refetch();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Commission Periods</h1>
<p className="text-sm text-gray-500">
Manage payment periods and payouts
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Period
</button>
</div>
<div className="flex gap-4 items-center">
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Status</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="processing">Processing</option>
<option value="paid">Paid</option>
</select>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<PeriodManager
periods={data?.data || []}
total={data?.total || 0}
page={data?.page || 1}
totalPages={data?.totalPages || 1}
onPageChange={handlePageChange}
onRefresh={refetch}
/>
)}
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Create New Period</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleCreatePeriod({
name: formData.get('name') as string,
starts_at: formData.get('starts_at') as string,
ends_at: formData.get('ends_at') as string,
});
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700">Period Name</label>
<input
name="name"
type="text"
required
placeholder="e.g., January 2026"
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Start Date</label>
<input
name="starts_at"
type="date"
required
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">End Date</label>
<input
name="ends_at"
type="date"
required
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createPeriodMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Create
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,105 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useSchemes } from '../../../hooks/commissions';
import { SchemesList } from '../../../components/commissions/SchemesList';
import { SchemeForm } from '../../../components/commissions/SchemeForm';
import { SchemeFilters, SchemeType } from '../../../services/commissions/schemes.api';
export default function SchemesPage() {
const [showForm, setShowForm] = useState(false);
const [filters, setFilters] = useState<SchemeFilters>({
page: 1,
limit: 20,
});
const { data, isLoading, refetch } = useSchemes(filters);
const handleFilterChange = (key: keyof SchemeFilters, value: unknown) => {
setFilters((prev) => ({ ...prev, [key]: value, page: 1 }));
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Commission Schemes</h1>
<p className="text-sm text-gray-500">
Configure commission structures and rates
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Scheme
</button>
</div>
<div className="flex gap-4 items-center">
<select
value={filters.type || ''}
onChange={(e) => handleFilterChange('type', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Types</option>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed</option>
<option value="tiered">Tiered</option>
</select>
<select
value={filters.is_active === undefined ? '' : filters.is_active.toString()}
onChange={(e) =>
handleFilterChange(
'is_active',
e.target.value === '' ? undefined : e.target.value === 'true'
)
}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<input
type="text"
placeholder="Search schemes..."
value={filters.search || ''}
onChange={(e) => handleFilterChange('search', e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm flex-1"
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : (
<SchemesList
schemes={data?.data || []}
total={data?.total || 0}
page={data?.page || 1}
totalPages={data?.totalPages || 1}
onPageChange={handlePageChange}
onRefresh={refetch}
/>
)}
{showForm && (
<SchemeForm
onClose={() => setShowForm(false)}
onSuccess={() => {
setShowForm(false);
refetch();
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,104 @@
import api from '../api';
import { CommissionScheme } from './schemes.api';
export interface CommissionAssignment {
id: string;
tenant_id: string;
user_id: string;
scheme_id: string;
starts_at: string;
ends_at: string | null;
custom_rate: number | null;
is_active: boolean;
created_at: string;
created_by: string | null;
scheme?: CommissionScheme;
user?: {
id: string;
first_name: string;
last_name: string;
email: string;
};
}
export interface CreateAssignmentDto {
user_id: string;
scheme_id: string;
starts_at?: string;
ends_at?: string;
custom_rate?: number;
is_active?: boolean;
}
export interface UpdateAssignmentDto {
ends_at?: string;
custom_rate?: number;
is_active?: boolean;
}
export interface AssignmentFilters {
user_id?: string;
scheme_id?: string;
is_active?: boolean;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedAssignments {
data: CommissionAssignment[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const assignmentsApi = {
list: async (params?: AssignmentFilters): Promise<PaginatedAssignments> => {
const response = await api.get<PaginatedAssignments>('/commissions/assignments', { params });
return response.data;
},
get: async (id: string): Promise<CommissionAssignment> => {
const response = await api.get<CommissionAssignment>(`/commissions/assignments/${id}`);
return response.data;
},
getByUser: async (userId: string): Promise<CommissionAssignment[]> => {
const response = await api.get<CommissionAssignment[]>(`/commissions/assignments/user/${userId}`);
return response.data;
},
getActiveScheme: async (userId: string): Promise<CommissionAssignment | null> => {
const response = await api.get<CommissionAssignment | { message: string }>(`/commissions/assignments/user/${userId}/active`);
if ('message' in response.data) {
return null;
}
return response.data;
},
getSchemeAssignees: async (schemeId: string): Promise<CommissionAssignment[]> => {
const response = await api.get<CommissionAssignment[]>(`/commissions/assignments/scheme/${schemeId}/users`);
return response.data;
},
assign: async (data: CreateAssignmentDto): Promise<CommissionAssignment> => {
const response = await api.post<CommissionAssignment>('/commissions/assignments', data);
return response.data;
},
update: async (id: string, data: UpdateAssignmentDto): Promise<CommissionAssignment> => {
const response = await api.patch<CommissionAssignment>(`/commissions/assignments/${id}`, data);
return response.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/commissions/assignments/${id}`);
},
deactivate: async (id: string): Promise<CommissionAssignment> => {
const response = await api.post<CommissionAssignment>(`/commissions/assignments/${id}/deactivate`);
return response.data;
},
};

View File

@ -0,0 +1,101 @@
import api from '../api';
import { CommissionEntry } from './entries.api';
import { PeriodStatus } from './periods.api';
export interface DashboardSummary {
total_pending: number;
total_approved: number;
total_paid: number;
pending_count: number;
approved_count: number;
paid_count: number;
active_schemes: number;
active_assignments: number;
open_periods: number;
currency: string;
}
export interface UserEarnings {
user_id: string;
user_name: string;
user_email: string;
pending: number;
approved: number;
paid: number;
total: number;
entries_count: number;
}
export interface PeriodEarnings {
period_id: string;
period_name: string;
starts_at: string;
ends_at: string;
status: PeriodStatus;
total_amount: number;
entries_count: number;
}
export interface TopEarner {
user_id: string;
user_name: string;
total_earned: number;
entries_count: number;
rank: number;
}
export interface MyEarnings {
pending: number;
approved: number;
paid: number;
total: number;
current_period_earnings: number;
last_paid_date: string | null;
entries_count: number;
recent_entries: CommissionEntry[];
}
export interface SchemePerformance {
total_generated: number;
total_paid: number;
entries_count: number;
avg_commission: number;
active_users: number;
}
export const dashboardApi = {
getSummary: async (): Promise<DashboardSummary> => {
const response = await api.get<DashboardSummary>('/commissions/dashboard/summary');
return response.data;
},
getEarningsByUser: async (params?: { date_from?: string; date_to?: string }): Promise<UserEarnings[]> => {
const response = await api.get<UserEarnings[]>('/commissions/dashboard/by-user', { params });
return response.data;
},
getEarningsByPeriod: async (): Promise<PeriodEarnings[]> => {
const response = await api.get<PeriodEarnings[]>('/commissions/dashboard/by-period');
return response.data;
},
getTopEarners: async (params?: { limit?: number; date_from?: string; date_to?: string }): Promise<TopEarner[]> => {
const response = await api.get<TopEarner[]>('/commissions/dashboard/top-earners', { params });
return response.data;
},
getMyEarnings: async (): Promise<MyEarnings> => {
const response = await api.get<MyEarnings>('/commissions/dashboard/my-earnings');
return response.data;
},
getUserEarnings: async (userId: string): Promise<MyEarnings> => {
const response = await api.get<MyEarnings>(`/commissions/dashboard/user/${userId}/earnings`);
return response.data;
},
getSchemePerformance: async (schemeId: string): Promise<SchemePerformance> => {
const response = await api.get<SchemePerformance>(`/commissions/dashboard/scheme/${schemeId}/performance`);
return response.data;
},
};

View File

@ -0,0 +1,147 @@
import api from '../api';
import { CommissionScheme } from './schemes.api';
export type EntryStatus = 'pending' | 'approved' | 'rejected' | 'paid' | 'cancelled';
export interface CommissionEntry {
id: string;
tenant_id: string;
user_id: string;
scheme_id: string;
assignment_id: string | null;
reference_type: string;
reference_id: string;
base_amount: number;
rate_applied: number;
commission_amount: number;
currency: string;
status: EntryStatus;
period_id: string | null;
paid_at: string | null;
payment_reference: string | null;
notes: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
approved_by: string | null;
approved_at: string | null;
scheme?: CommissionScheme;
user?: {
id: string;
first_name: string;
last_name: string;
email: string;
};
}
export interface CalculateCommissionDto {
user_id: string;
reference_type: string;
reference_id: string;
base_amount: number;
currency?: string;
notes?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateEntryStatusDto {
status: EntryStatus;
notes?: string;
}
export interface CalculationResult {
rate_applied: number;
commission_amount: number;
scheme_id: string;
assignment_id: string | null;
}
export interface EntryFilters {
user_id?: string;
scheme_id?: string;
period_id?: string;
status?: EntryStatus;
reference_type?: string;
date_from?: string;
date_to?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedEntries {
data: CommissionEntry[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const entriesApi = {
list: async (params?: EntryFilters): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>('/commissions/entries', { params });
return response.data;
},
listPending: async (params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>('/commissions/entries/pending', { params });
return response.data;
},
get: async (id: string): Promise<CommissionEntry> => {
const response = await api.get<CommissionEntry>(`/commissions/entries/${id}`);
return response.data;
},
getByUser: async (userId: string, params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>(`/commissions/entries/user/${userId}`, { params });
return response.data;
},
getByPeriod: async (periodId: string, params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>(`/commissions/entries/period/${periodId}`, { params });
return response.data;
},
getByReference: async (type: string, refId: string): Promise<CommissionEntry | null> => {
const response = await api.get<CommissionEntry | { message: string }>(`/commissions/entries/reference/${type}/${refId}`);
if ('message' in response.data) {
return null;
}
return response.data;
},
calculate: async (data: CalculateCommissionDto): Promise<CommissionEntry> => {
const response = await api.post<CommissionEntry>('/commissions/entries/calculate', data);
return response.data;
},
simulate: async (data: CalculateCommissionDto): Promise<CalculationResult> => {
const response = await api.post<CalculationResult>('/commissions/entries/simulate', data);
return response.data;
},
updateStatus: async (id: string, data: UpdateEntryStatusDto): Promise<CommissionEntry> => {
const response = await api.patch<CommissionEntry>(`/commissions/entries/${id}/status`, data);
return response.data;
},
bulkApprove: async (entryIds: string[]): Promise<{ approved: number; failed: number }> => {
const response = await api.post<{ approved: number; failed: number }>('/commissions/entries/bulk-approve', { entry_ids: entryIds });
return response.data;
},
bulkReject: async (entryIds: string[], reason?: string): Promise<{ rejected: number; failed: number }> => {
const response = await api.post<{ rejected: number; failed: number }>('/commissions/entries/bulk-reject', { entry_ids: entryIds, reason });
return response.data;
},
cancelByReference: async (type: string, refId: string, reason?: string): Promise<CommissionEntry | null> => {
const response = await api.post<CommissionEntry | { message: string }>(`/commissions/entries/cancel/${type}/${refId}`, { reason });
if ('message' in response.data) {
return null;
}
return response.data;
},
};

View File

@ -0,0 +1,5 @@
export * from './schemes.api';
export * from './assignments.api';
export * from './entries.api';
export * from './periods.api';
export * from './dashboard.api';

View File

@ -0,0 +1,121 @@
import api from '../api';
export type PeriodStatus = 'open' | 'closed' | 'processing' | 'paid';
export interface CommissionPeriod {
id: string;
tenant_id: string;
name: string;
starts_at: string;
ends_at: string;
total_entries: number;
total_amount: number;
currency: string;
status: PeriodStatus;
closed_at: string | null;
closed_by: string | null;
paid_at: string | null;
paid_by: string | null;
payment_reference: string | null;
payment_notes: string | null;
created_at: string;
created_by: string | null;
}
export interface CreatePeriodDto {
name: string;
starts_at: string;
ends_at: string;
currency?: string;
}
export interface MarkPeriodPaidDto {
payment_reference?: string;
payment_notes?: string;
}
export interface PeriodFilters {
status?: PeriodStatus;
date_from?: string;
date_to?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedPeriods {
data: CommissionPeriod[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PeriodSummary {
total_entries: number;
total_amount: number;
by_status: {
pending: number;
approved: number;
rejected: number;
paid: number;
cancelled: number;
};
by_user: Array<{
user_id: string;
user_name: string;
entries_count: number;
total_amount: number;
}>;
}
export const periodsApi = {
list: async (params?: PeriodFilters): Promise<PaginatedPeriods> => {
const response = await api.get<PaginatedPeriods>('/commissions/periods', { params });
return response.data;
},
getOpen: async (): Promise<CommissionPeriod | null> => {
const response = await api.get<CommissionPeriod | { message: string }>('/commissions/periods/open');
if ('message' in response.data) {
return null;
}
return response.data;
},
get: async (id: string): Promise<CommissionPeriod> => {
const response = await api.get<CommissionPeriod>(`/commissions/periods/${id}`);
return response.data;
},
getSummary: async (id: string): Promise<PeriodSummary> => {
const response = await api.get<PeriodSummary>(`/commissions/periods/${id}/summary`);
return response.data;
},
create: async (data: CreatePeriodDto): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>('/commissions/periods', data);
return response.data;
},
close: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/close`);
return response.data;
},
reopen: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/reopen`);
return response.data;
},
markAsProcessing: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/processing`);
return response.data;
},
markAsPaid: async (id: string, data?: MarkPeriodPaidDto): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/pay`, data || {});
return response.data;
},
};

View File

@ -0,0 +1,107 @@
import api from '../api';
// Types
export type SchemeType = 'percentage' | 'fixed' | 'tiered';
export type AppliesTo = 'all' | 'products' | 'categories';
export interface TierConfig {
from: number;
to: number | null;
rate: number;
}
export interface CommissionScheme {
id: string;
tenant_id: string;
name: string;
description: string | null;
type: SchemeType;
rate: number;
fixed_amount: number;
tiers: TierConfig[];
applies_to: AppliesTo;
product_ids: string[];
category_ids: string[];
min_amount: number;
max_amount: number | null;
is_active: boolean;
created_at: string;
updated_at: string;
created_by: string | null;
}
export interface CreateSchemeDto {
name: string;
description?: string;
type: SchemeType;
rate?: number;
fixed_amount?: number;
tiers?: TierConfig[];
applies_to?: AppliesTo;
product_ids?: string[];
category_ids?: string[];
min_amount?: number;
max_amount?: number;
is_active?: boolean;
}
export interface UpdateSchemeDto extends Partial<CreateSchemeDto> {}
export interface SchemeFilters {
type?: SchemeType;
is_active?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedSchemes {
data: CommissionScheme[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const schemesApi = {
list: async (params?: SchemeFilters): Promise<PaginatedSchemes> => {
const response = await api.get<PaginatedSchemes>('/commissions/schemes', { params });
return response.data;
},
listActive: async (): Promise<CommissionScheme[]> => {
const response = await api.get<CommissionScheme[]>('/commissions/schemes/active');
return response.data;
},
get: async (id: string): Promise<CommissionScheme> => {
const response = await api.get<CommissionScheme>(`/commissions/schemes/${id}`);
return response.data;
},
create: async (data: CreateSchemeDto): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>('/commissions/schemes', data);
return response.data;
},
update: async (id: string, data: UpdateSchemeDto): Promise<CommissionScheme> => {
const response = await api.patch<CommissionScheme>(`/commissions/schemes/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/commissions/schemes/${id}`);
},
duplicate: async (id: string, name?: string): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>(`/commissions/schemes/${id}/duplicate`, { name });
return response.data;
},
toggleActive: async (id: string): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>(`/commissions/schemes/${id}/toggle-active`);
return response.data;
},
};

@ -1 +1 @@
Subproject commit b49a051d854962a0eeefb1dc73c96c9d073f36a8
Subproject commit eb6a83daba38dd35f1ec9cdfe3eab9ee4d876c30

@ -1 +1 @@
Subproject commit ea4f8b18a0ee8a9c63667b36994d2ec11bb9d943
Subproject commit 8915b7ce71f305cebe449137422feb983ecf82a4

@ -1 +1 @@
Subproject commit f59bbfac644374479c650a7853c39c4dc71d96ce
Subproject commit 36ee5213c53ed1783f0e2f0e90f357d6c26af0c1

View File

@ -1,13 +1,13 @@
---
# DATABASE INVENTORY - Template SaaS
# Version: 3.1.0
# Version: 3.2.0
# Ultima actualizacion: 2026-01-24
# Nota: Sales module (SAAS-018) added
# Nota: Commissions module (SAAS-020) added
metadata:
proyecto: "template-saas"
tipo: "DATABASE"
version: "3.1.0"
version: "3.2.0"
updated: "2026-01-24"
motor: "PostgreSQL 16+"
version_motor: "16.x"
@ -256,19 +256,70 @@ schemas:
- activities_due_date_idx
nota: "SAAS-018 - Implementado 2026-01-24"
- nombre: "commissions"
descripcion: "Sistema de comisiones - Esquemas, Asignaciones, Entradas, Períodos"
estado: "completado"
tablas:
- schemes
- assignments
- entries
- periods
enums:
- scheme_type
- applies_to
- entry_status
- period_status
funciones:
- calculate_commission
- close_period
- get_user_earnings
- apply_tiered_rate
rls_policies:
- schemes_tenant_isolation_select
- schemes_tenant_isolation_insert
- schemes_tenant_isolation_update
- schemes_tenant_isolation_delete
- assignments_tenant_isolation_select
- assignments_tenant_isolation_insert
- assignments_tenant_isolation_update
- assignments_tenant_isolation_delete
- entries_tenant_isolation_select
- entries_tenant_isolation_insert
- entries_tenant_isolation_update
- entries_tenant_isolation_delete
- periods_tenant_isolation_select
- periods_tenant_isolation_insert
- periods_tenant_isolation_update
- periods_tenant_isolation_delete
indices:
- schemes_tenant_active_idx
- schemes_type_idx
- assignments_tenant_user_idx
- assignments_scheme_idx
- assignments_active_dates_idx
- entries_tenant_user_idx
- entries_scheme_idx
- entries_status_idx
- entries_period_idx
- entries_reference_idx
- entries_created_at_idx
- periods_tenant_status_idx
- periods_dates_idx
nota: "SAAS-020 - Implementado 2026-01-24"
resumen:
total_schemas: 13
total_tablas: 30
total_enums: 37
total_funciones: 30
total_rls_policies: 28
total_indices: 57
total_schemas: 14
total_tablas: 34
total_enums: 41
total_funciones: 34
total_rls_policies: 44
total_indices: 70
planificado:
tablas_actuales: 30
tablas_objetivo: 30
tablas_actuales: 34
tablas_objetivo: 34
estado: "100%"
nota: "Sales module (SAAS-018) added 2026-01-24"
nota: "Commissions module (SAAS-020) added 2026-01-24"
ddl_structure:
base_files:
@ -296,4 +347,4 @@ scripts:
- drop-and-recreate.sh
ultima_actualizacion: "2026-01-24"
actualizado_por: "Claude Opus 4.5 (SAAS-018 Sales Foundation)"
actualizado_por: "Claude Opus 4.5 (SAAS-020 Commissions)"