[SAAS-018/020] feat: Complete Sales and Commissions modules implementation
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:
parent
fdc75d18b9
commit
2e6ecee8ea
@ -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 {}
|
||||
|
||||
55
apps/backend/src/modules/commissions/commissions.module.ts
Normal file
55
apps/backend/src/modules/commissions/commissions.module.ts
Normal 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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export * from './schemes.controller';
|
||||
export * from './assignments.controller';
|
||||
export * from './entries.controller';
|
||||
export * from './periods.controller';
|
||||
export * from './dashboard.controller';
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
103
apps/backend/src/modules/commissions/dto/create-scheme.dto.ts
Normal file
103
apps/backend/src/modules/commissions/dto/create-scheme.dto.ts
Normal 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;
|
||||
}
|
||||
7
apps/backend/src/modules/commissions/dto/index.ts
Normal file
7
apps/backend/src/modules/commissions/dto/index.ts
Normal 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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateSchemeDto } from './create-scheme.dto';
|
||||
|
||||
export class UpdateSchemeDto extends PartialType(CreateSchemeDto) {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
4
apps/backend/src/modules/commissions/entities/index.ts
Normal file
4
apps/backend/src/modules/commissions/entities/index.ts
Normal 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';
|
||||
@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
357
apps/backend/src/modules/commissions/services/entries.service.ts
Normal file
357
apps/backend/src/modules/commissions/services/entries.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
apps/backend/src/modules/commissions/services/index.ts
Normal file
5
apps/backend/src/modules/commissions/services/index.ts
Normal 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';
|
||||
288
apps/backend/src/modules/commissions/services/periods.service.ts
Normal file
288
apps/backend/src/modules/commissions/services/periods.service.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
200
apps/backend/src/modules/commissions/services/schemes.service.ts
Normal file
200
apps/backend/src/modules/commissions/services/schemes.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/src/components/commissions/EarningsCard.tsx
Normal file
42
apps/frontend/src/components/commissions/EarningsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
apps/frontend/src/components/commissions/EntriesList.tsx
Normal file
151
apps/frontend/src/components/commissions/EntriesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
205
apps/frontend/src/components/commissions/PeriodManager.tsx
Normal file
205
apps/frontend/src/components/commissions/PeriodManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
apps/frontend/src/components/commissions/SchemeForm.tsx
Normal file
266
apps/frontend/src/components/commissions/SchemeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/frontend/src/components/commissions/SchemesList.tsx
Normal file
185
apps/frontend/src/components/commissions/SchemesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/frontend/src/components/commissions/index.ts
Normal file
7
apps/frontend/src/components/commissions/index.ts
Normal 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';
|
||||
5
apps/frontend/src/hooks/commissions/index.ts
Normal file
5
apps/frontend/src/hooks/commissions/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './useSchemes';
|
||||
export * from './useAssignments';
|
||||
export * from './useEntries';
|
||||
export * from './usePeriods';
|
||||
export * from './useCommissionsDashboard';
|
||||
112
apps/frontend/src/hooks/commissions/useAssignments.ts
Normal file
112
apps/frontend/src/hooks/commissions/useAssignments.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
135
apps/frontend/src/hooks/commissions/useEntries.ts
Normal file
135
apps/frontend/src/hooks/commissions/useEntries.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
111
apps/frontend/src/hooks/commissions/usePeriods.ts
Normal file
111
apps/frontend/src/hooks/commissions/usePeriods.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
101
apps/frontend/src/hooks/commissions/useSchemes.ts
Normal file
101
apps/frontend/src/hooks/commissions/useSchemes.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
128
apps/frontend/src/pages/commissions/entries/index.tsx
Normal file
128
apps/frontend/src/pages/commissions/entries/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/frontend/src/pages/commissions/index.tsx
Normal file
37
apps/frontend/src/pages/commissions/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/frontend/src/pages/commissions/my-earnings/index.tsx
Normal file
125
apps/frontend/src/pages/commissions/my-earnings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
apps/frontend/src/pages/commissions/periods/index.tsx
Normal file
146
apps/frontend/src/pages/commissions/periods/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/src/pages/commissions/schemes/index.tsx
Normal file
105
apps/frontend/src/pages/commissions/schemes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
apps/frontend/src/services/commissions/assignments.api.ts
Normal file
104
apps/frontend/src/services/commissions/assignments.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
101
apps/frontend/src/services/commissions/dashboard.api.ts
Normal file
101
apps/frontend/src/services/commissions/dashboard.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
147
apps/frontend/src/services/commissions/entries.api.ts
Normal file
147
apps/frontend/src/services/commissions/entries.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
5
apps/frontend/src/services/commissions/index.ts
Normal file
5
apps/frontend/src/services/commissions/index.ts
Normal 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';
|
||||
121
apps/frontend/src/services/commissions/periods.api.ts
Normal file
121
apps/frontend/src/services/commissions/periods.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
107
apps/frontend/src/services/commissions/schemes.api.ts
Normal file
107
apps/frontend/src/services/commissions/schemes.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
2
backend
2
backend
@ -1 +1 @@
|
||||
Subproject commit b49a051d854962a0eeefb1dc73c96c9d073f36a8
|
||||
Subproject commit eb6a83daba38dd35f1ec9cdfe3eab9ee4d876c30
|
||||
2
database
2
database
@ -1 +1 @@
|
||||
Subproject commit ea4f8b18a0ee8a9c63667b36994d2ec11bb9d943
|
||||
Subproject commit 8915b7ce71f305cebe449137422feb983ecf82a4
|
||||
2
frontend
2
frontend
@ -1 +1 @@
|
||||
Subproject commit f59bbfac644374479c650a7853c39c4dc71d96ce
|
||||
Subproject commit 36ee5213c53ed1783f0e2f0e90f357d6c26af0c1
|
||||
@ -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)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user