diff --git a/src/app.module.ts b/src/app.module.ts index 8807522..851bcbd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,8 @@ import { OnboardingModule } from '@modules/onboarding/onboarding.module'; import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module'; import { AnalyticsModule } from '@modules/analytics/analytics.module'; import { ReportsModule } from '@modules/reports/reports.module'; +import { SalesModule } from '@modules/sales/sales.module'; +import { CommissionsModule } from '@modules/commissions/commissions.module'; @Module({ imports: [ @@ -88,6 +90,8 @@ import { ReportsModule } from '@modules/reports/reports.module'; WhatsAppModule, AnalyticsModule, ReportsModule, + SalesModule, + CommissionsModule, ], }) export class AppModule {} diff --git a/src/modules/commissions/commissions.module.ts b/src/modules/commissions/commissions.module.ts new file mode 100644 index 0000000..9686d09 --- /dev/null +++ b/src/modules/commissions/commissions.module.ts @@ -0,0 +1,58 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { + CommissionSchemeEntity, + CommissionAssignmentEntity, + CommissionPeriodEntity, + CommissionEntryEntity, +} from './entities'; + +import { + SchemesService, + AssignmentsService, + EntriesService, + PeriodsService, + CommissionsDashboardService, +} from './services'; + +import { + SchemesController, + AssignmentsController, + EntriesController, + PeriodsController, + CommissionsDashboardController, +} from './controllers'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CommissionSchemeEntity, + CommissionAssignmentEntity, + CommissionPeriodEntity, + CommissionEntryEntity, + ]), + ], + controllers: [ + SchemesController, + AssignmentsController, + EntriesController, + PeriodsController, + CommissionsDashboardController, + ], + providers: [ + SchemesService, + AssignmentsService, + EntriesService, + PeriodsService, + CommissionsDashboardService, + ], + exports: [ + SchemesService, + AssignmentsService, + EntriesService, + PeriodsService, + CommissionsDashboardService, + ], +}) +export class CommissionsModule {} diff --git a/src/modules/commissions/controllers/assignments.controller.ts b/src/modules/commissions/controllers/assignments.controller.ts new file mode 100644 index 0000000..9ea35ec --- /dev/null +++ b/src/modules/commissions/controllers/assignments.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { AssignmentsService } from '../services'; +import { + CreateAssignmentDto, + UpdateAssignmentDto, + AssignmentListQueryDto, + AssignmentResponseDto, + PaginatedAssignmentsDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('commissions/assignments') +@UseGuards(JwtAuthGuard) +export class AssignmentsController { + constructor(private readonly assignmentsService: AssignmentsService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: AssignmentListQueryDto, + ): Promise { + return this.assignmentsService.findAll(user.tenant_id, query); + } + + @Get('my') + async findMyAssignments(@CurrentUser() user: RequestUser): Promise { + return this.assignmentsService.findActiveForUser(user.tenant_id, user.id); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.assignmentsService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateAssignmentDto, + ): Promise { + return this.assignmentsService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAssignmentDto, + ): Promise { + return this.assignmentsService.update(user.tenant_id, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.assignmentsService.remove(user.tenant_id, id); + return { message: 'Commission assignment deleted successfully' }; + } +} diff --git a/src/modules/commissions/controllers/dashboard.controller.ts b/src/modules/commissions/controllers/dashboard.controller.ts new file mode 100644 index 0000000..7c4ba33 --- /dev/null +++ b/src/modules/commissions/controllers/dashboard.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { CommissionsDashboardService } from '../services'; +import { + CommissionsDashboardDto, + UserEarningsDto, + EntriesByStatusDto, + EntriesBySchemeDto, + EntriesByUserDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('commissions/dashboard') +@UseGuards(JwtAuthGuard) +export class CommissionsDashboardController { + constructor(private readonly dashboardService: CommissionsDashboardService) {} + + @Get() + async getSummary(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getDashboardSummary(user.tenant_id); + } + + @Get('my-earnings') + async getMyEarnings(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getUserEarnings(user.tenant_id, user.id); + } + + @Get('entries-by-status') + async getEntriesByStatus(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getEntriesByStatus(user.tenant_id); + } + + @Get('entries-by-scheme') + async getEntriesByScheme(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getEntriesByScheme(user.tenant_id); + } + + @Get('entries-by-user') + async getEntriesByUser(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getEntriesByUser(user.tenant_id); + } + + @Get('top-earners') + async getTopEarners( + @CurrentUser() user: RequestUser, + @Query('limit') limit?: number, + ): Promise { + return this.dashboardService.getTopEarners(user.tenant_id, limit || 10); + } +} diff --git a/src/modules/commissions/controllers/entries.controller.ts b/src/modules/commissions/controllers/entries.controller.ts new file mode 100644 index 0000000..b1c1b3c --- /dev/null +++ b/src/modules/commissions/controllers/entries.controller.ts @@ -0,0 +1,118 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { EntriesService } from '../services'; +import { + CreateEntryDto, + UpdateEntryDto, + ApproveEntryDto, + RejectEntryDto, + EntryListQueryDto, + EntryResponseDto, + PaginatedEntriesDto, + CalculateCommissionDto, + CalculateCommissionResponseDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('commissions/entries') +@UseGuards(JwtAuthGuard) +export class EntriesController { + constructor(private readonly entriesService: EntriesService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: EntryListQueryDto, + ): Promise { + return this.entriesService.findAll(user.tenant_id, query); + } + + @Get('my') + async findMyEntries( + @CurrentUser() user: RequestUser, + @Query() query: EntryListQueryDto, + ): Promise { + return this.entriesService.findAll(user.tenant_id, { ...query, userId: user.id }); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.entriesService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateEntryDto, + ): Promise { + return this.entriesService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEntryDto, + ): Promise { + return this.entriesService.update(user.tenant_id, id, dto); + } + + @Post(':id/approve') + async approve( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveEntryDto, + ): Promise { + return this.entriesService.approve(user.tenant_id, id, user.id, dto); + } + + @Post(':id/reject') + async reject( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectEntryDto, + ): Promise { + return this.entriesService.reject(user.tenant_id, id, user.id, dto); + } + + @Post('calculate') + async calculateCommission( + @CurrentUser() user: RequestUser, + @Body() dto: CalculateCommissionDto, + ): Promise { + return this.entriesService.calculateCommission(user.tenant_id, dto); + } + + @Post('bulk-approve') + async bulkApprove( + @CurrentUser() user: RequestUser, + @Body() body: { entryIds: string[] }, + ): Promise<{ approved: number }> { + const approved = await this.entriesService.bulkApprove( + user.tenant_id, + body.entryIds, + user.id, + ); + return { approved }; + } +} diff --git a/src/modules/commissions/controllers/index.ts b/src/modules/commissions/controllers/index.ts new file mode 100644 index 0000000..3f912aa --- /dev/null +++ b/src/modules/commissions/controllers/index.ts @@ -0,0 +1,5 @@ +export * from './schemes.controller'; +export * from './assignments.controller'; +export * from './entries.controller'; +export * from './periods.controller'; +export * from './dashboard.controller'; diff --git a/src/modules/commissions/controllers/periods.controller.ts b/src/modules/commissions/controllers/periods.controller.ts new file mode 100644 index 0000000..423444c --- /dev/null +++ b/src/modules/commissions/controllers/periods.controller.ts @@ -0,0 +1,116 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { PeriodsService } from '../services'; +import { + CreatePeriodDto, + UpdatePeriodDto, + ClosePeriodDto, + MarkPaidDto, + PeriodListQueryDto, + PeriodResponseDto, + PaginatedPeriodsDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('commissions/periods') +@UseGuards(JwtAuthGuard) +export class PeriodsController { + constructor(private readonly periodsService: PeriodsService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: PeriodListQueryDto, + ): Promise { + return this.periodsService.findAll(user.tenant_id, query); + } + + @Get('current') + async getCurrentPeriod(@CurrentUser() user: RequestUser): Promise { + return this.periodsService.getCurrentPeriod(user.tenant_id); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.periodsService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreatePeriodDto, + ): Promise { + return this.periodsService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePeriodDto, + ): Promise { + return this.periodsService.update(user.tenant_id, id, dto); + } + + @Post(':id/close') + async close( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ClosePeriodDto, + ): Promise { + return this.periodsService.close(user.tenant_id, id, user.id, dto); + } + + @Post(':id/mark-paid') + async markPaid( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MarkPaidDto, + ): Promise { + return this.periodsService.markAsPaid(user.tenant_id, id, user.id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.periodsService.remove(user.tenant_id, id); + return { message: 'Commission period deleted successfully' }; + } + + @Post(':id/assign-entries') + async assignEntries( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { entryIds: string[] }, + ): Promise<{ assigned: number }> { + const assigned = await this.periodsService.assignEntriesToPeriod( + user.tenant_id, + id, + body.entryIds, + ); + return { assigned }; + } +} diff --git a/src/modules/commissions/controllers/schemes.controller.ts b/src/modules/commissions/controllers/schemes.controller.ts new file mode 100644 index 0000000..1c68189 --- /dev/null +++ b/src/modules/commissions/controllers/schemes.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { SchemesService } from '../services'; +import { + CreateSchemeDto, + UpdateSchemeDto, + SchemeListQueryDto, + SchemeResponseDto, + PaginatedSchemesDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('commissions/schemes') +@UseGuards(JwtAuthGuard) +export class SchemesController { + constructor(private readonly schemesService: SchemesService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: SchemeListQueryDto, + ): Promise { + return this.schemesService.findAll(user.tenant_id, query); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.schemesService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateSchemeDto, + ): Promise { + return this.schemesService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSchemeDto, + ): Promise { + return this.schemesService.update(user.tenant_id, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.schemesService.remove(user.tenant_id, id); + return { message: 'Commission scheme deleted successfully' }; + } + + @Post(':id/activate') + async activate( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.schemesService.activate(user.tenant_id, id); + } + + @Post(':id/deactivate') + async deactivate( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.schemesService.deactivate(user.tenant_id, id); + } +} diff --git a/src/modules/commissions/dto/assignment.dto.ts b/src/modules/commissions/dto/assignment.dto.ts new file mode 100644 index 0000000..d72097d --- /dev/null +++ b/src/modules/commissions/dto/assignment.dto.ts @@ -0,0 +1,98 @@ +import { + IsUUID, + IsOptional, + IsNumber, + IsBoolean, + IsDateString, + Min, + Max, +} from 'class-validator'; + +export class CreateAssignmentDto { + @IsUUID() + userId: string; + + @IsUUID() + schemeId: string; + + @IsDateString() + @IsOptional() + startsAt?: string; + + @IsDateString() + @IsOptional() + endsAt?: string; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + customRate?: number; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class UpdateAssignmentDto { + @IsDateString() + @IsOptional() + startsAt?: string; + + @IsDateString() + @IsOptional() + endsAt?: string; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + customRate?: number; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class AssignmentResponseDto { + id: string; + tenantId: string; + userId: string; + schemeId: string; + startsAt: Date; + endsAt: Date | null; + customRate: number | null; + isActive: boolean; + createdAt: Date; + createdBy: string | null; + scheme?: any; + user?: any; +} + +export class AssignmentListQueryDto { + @IsUUID() + @IsOptional() + userId?: string; + + @IsUUID() + @IsOptional() + schemeId?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedAssignmentsDto { + items: AssignmentResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/src/modules/commissions/dto/dashboard.dto.ts b/src/modules/commissions/dto/dashboard.dto.ts new file mode 100644 index 0000000..4cdd063 --- /dev/null +++ b/src/modules/commissions/dto/dashboard.dto.ts @@ -0,0 +1,60 @@ +import { EntryStatus, PeriodStatus } from '../entities'; + +export class CommissionsDashboardDto { + totalSchemes: number; + totalActiveAssignments: number; + pendingCommissions: number; + pendingAmount: number; + approvedAmount: number; + paidAmount: number; + currentPeriod: string | null; + currency: string; +} + +export class UserEarningsDto { + totalPending: number; + totalApproved: number; + totalPaid: number; + totalEntries: number; + currency: string; +} + +export class EntriesByStatusDto { + status: EntryStatus; + count: number; + totalAmount: number; + percentage: number; +} + +export class EntriesBySchemeDto { + schemeId: string; + schemeName: string; + count: number; + totalAmount: number; + percentage: number; +} + +export class EntriesByUserDto { + userId: string; + userName: string; + count: number; + totalAmount: number; + percentage: number; +} + +export class PeriodSummaryDto { + periodId: string; + periodName: string; + status: PeriodStatus; + totalEntries: number; + totalAmount: number; + entriesByStatus: EntriesByStatusDto[]; +} + +export class CommissionsTimelineDto { + date: string; + entriesCreated: number; + entriesApproved: number; + entriesPaid: number; + totalAmount: number; +} diff --git a/src/modules/commissions/dto/entry.dto.ts b/src/modules/commissions/dto/entry.dto.ts new file mode 100644 index 0000000..d7b83c8 --- /dev/null +++ b/src/modules/commissions/dto/entry.dto.ts @@ -0,0 +1,155 @@ +import { + IsString, + IsUUID, + IsOptional, + IsNumber, + IsEnum, + IsObject, + MaxLength, + Min, +} from 'class-validator'; +import { EntryStatus } from '../entities'; + +export class CreateEntryDto { + @IsUUID() + userId: string; + + @IsUUID() + schemeId: string; + + @IsUUID() + @IsOptional() + assignmentId?: string; + + @IsString() + @MaxLength(50) + referenceType: string; + + @IsUUID() + referenceId: string; + + @IsNumber() + @Min(0) + baseAmount: number; + + @IsString() + @MaxLength(3) + @IsOptional() + currency?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsObject() + @IsOptional() + metadata?: Record; +} + +export class UpdateEntryDto { + @IsEnum(EntryStatus) + @IsOptional() + status?: EntryStatus; + + @IsUUID() + @IsOptional() + periodId?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsObject() + @IsOptional() + metadata?: Record; +} + +export class ApproveEntryDto { + @IsString() + @IsOptional() + notes?: string; +} + +export class RejectEntryDto { + @IsString() + notes: string; +} + +export class EntryResponseDto { + id: string; + tenantId: string; + userId: string; + schemeId: string; + assignmentId: string | null; + referenceType: string; + referenceId: string; + baseAmount: number; + rateApplied: number; + commissionAmount: number; + currency: string; + status: EntryStatus; + periodId: string | null; + paidAt: Date | null; + paymentReference: string | null; + notes: string | null; + metadata: Record; + createdAt: Date; + updatedAt: Date; + approvedBy: string | null; + approvedAt: Date | null; + scheme?: any; + period?: any; +} + +export class EntryListQueryDto { + @IsUUID() + @IsOptional() + userId?: string; + + @IsUUID() + @IsOptional() + schemeId?: string; + + @IsUUID() + @IsOptional() + periodId?: string; + + @IsEnum(EntryStatus) + @IsOptional() + status?: EntryStatus; + + @IsString() + @IsOptional() + referenceType?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedEntriesDto { + items: EntryResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class CalculateCommissionDto { + @IsUUID() + schemeId: string; + + @IsUUID() + userId: string; + + @IsNumber() + @Min(0) + amount: number; +} + +export class CalculateCommissionResponseDto { + rateApplied: number; + commissionAmount: number; +} diff --git a/src/modules/commissions/dto/index.ts b/src/modules/commissions/dto/index.ts new file mode 100644 index 0000000..dfcc7d2 --- /dev/null +++ b/src/modules/commissions/dto/index.ts @@ -0,0 +1,5 @@ +export * from './scheme.dto'; +export * from './assignment.dto'; +export * from './entry.dto'; +export * from './period.dto'; +export * from './dashboard.dto'; diff --git a/src/modules/commissions/dto/period.dto.ts b/src/modules/commissions/dto/period.dto.ts new file mode 100644 index 0000000..2b37684 --- /dev/null +++ b/src/modules/commissions/dto/period.dto.ts @@ -0,0 +1,97 @@ +import { + IsString, + IsOptional, + IsEnum, + IsDateString, + MaxLength, +} from 'class-validator'; +import { PeriodStatus } from '../entities'; + +export class CreatePeriodDto { + @IsString() + @MaxLength(100) + name: string; + + @IsDateString() + startsAt: string; + + @IsDateString() + endsAt: string; + + @IsString() + @MaxLength(3) + @IsOptional() + currency?: string; +} + +export class UpdatePeriodDto { + @IsString() + @MaxLength(100) + @IsOptional() + name?: string; + + @IsDateString() + @IsOptional() + startsAt?: string; + + @IsDateString() + @IsOptional() + endsAt?: string; +} + +export class ClosePeriodDto { + @IsString() + @IsOptional() + notes?: string; +} + +export class MarkPaidDto { + @IsString() + @MaxLength(255) + @IsOptional() + paymentReference?: string; + + @IsString() + @IsOptional() + paymentNotes?: string; +} + +export class PeriodResponseDto { + id: string; + tenantId: string; + name: string; + startsAt: Date; + endsAt: Date; + totalEntries: number; + totalAmount: number; + currency: string; + status: PeriodStatus; + closedAt: Date | null; + closedBy: string | null; + paidAt: Date | null; + paidBy: string | null; + paymentReference: string | null; + paymentNotes: string | null; + createdAt: Date; + createdBy: string | null; +} + +export class PeriodListQueryDto { + @IsEnum(PeriodStatus) + @IsOptional() + status?: PeriodStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedPeriodsDto { + items: PeriodResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/src/modules/commissions/dto/scheme.dto.ts b/src/modules/commissions/dto/scheme.dto.ts new file mode 100644 index 0000000..b8ed182 --- /dev/null +++ b/src/modules/commissions/dto/scheme.dto.ts @@ -0,0 +1,192 @@ +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsBoolean, + IsArray, + IsUUID, + MaxLength, + Min, + Max, +} from 'class-validator'; +import { SchemeType, AppliesTo } from '../entities'; + +export class TierDto { + @IsNumber() + @Min(0) + from: number; + + @IsNumber() + @IsOptional() + to?: number; + + @IsNumber() + @Min(0) + @Max(100) + rate: number; +} + +export class CreateSchemeDto { + @IsString() + @MaxLength(100) + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsEnum(SchemeType) + @IsOptional() + type?: SchemeType; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + rate?: number; + + @IsNumber() + @Min(0) + @IsOptional() + fixedAmount?: number; + + @IsArray() + @IsOptional() + tiers?: TierDto[]; + + @IsEnum(AppliesTo) + @IsOptional() + appliesTo?: AppliesTo; + + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + productIds?: string[]; + + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + categoryIds?: string[]; + + @IsNumber() + @Min(0) + @IsOptional() + minAmount?: number; + + @IsNumber() + @Min(0) + @IsOptional() + maxAmount?: number; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class UpdateSchemeDto { + @IsString() + @MaxLength(100) + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsEnum(SchemeType) + @IsOptional() + type?: SchemeType; + + @IsNumber() + @Min(0) + @Max(100) + @IsOptional() + rate?: number; + + @IsNumber() + @Min(0) + @IsOptional() + fixedAmount?: number; + + @IsArray() + @IsOptional() + tiers?: TierDto[]; + + @IsEnum(AppliesTo) + @IsOptional() + appliesTo?: AppliesTo; + + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + productIds?: string[]; + + @IsArray() + @IsUUID('4', { each: true }) + @IsOptional() + categoryIds?: string[]; + + @IsNumber() + @Min(0) + @IsOptional() + minAmount?: number; + + @IsNumber() + @Min(0) + @IsOptional() + maxAmount?: number; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class SchemeResponseDto { + id: string; + tenantId: string; + name: string; + description: string | null; + type: SchemeType; + rate: number; + fixedAmount: number; + tiers: TierDto[]; + appliesTo: AppliesTo; + productIds: string[]; + categoryIds: string[]; + minAmount: number; + maxAmount: number | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + assignmentsCount?: number; +} + +export class SchemeListQueryDto { + @IsEnum(SchemeType) + @IsOptional() + type?: SchemeType; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsString() + @IsOptional() + search?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedSchemesDto { + items: SchemeResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/src/modules/commissions/entities/commission-assignment.entity.ts b/src/modules/commissions/entities/commission-assignment.entity.ts new file mode 100644 index 0000000..4c0c868 --- /dev/null +++ b/src/modules/commissions/entities/commission-assignment.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CommissionSchemeEntity } from './commission-scheme.entity'; + +@Entity({ schema: 'commissions', name: 'assignments' }) +export class CommissionAssignmentEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'scheme_id', type: 'uuid' }) + schemeId: string; + + @Column({ name: 'starts_at', type: 'timestamptz', default: () => 'NOW()' }) + startsAt: Date; + + @Column({ name: 'ends_at', type: 'timestamptz', nullable: true }) + endsAt: Date; + + @Column({ name: 'custom_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + customRate: number; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.assignments) + @JoinColumn({ name: 'scheme_id' }) + scheme: CommissionSchemeEntity; +} diff --git a/src/modules/commissions/entities/commission-entry.entity.ts b/src/modules/commissions/entities/commission-entry.entity.ts new file mode 100644 index 0000000..4ab020c --- /dev/null +++ b/src/modules/commissions/entities/commission-entry.entity.ts @@ -0,0 +1,103 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CommissionSchemeEntity } from './commission-scheme.entity'; +import { CommissionAssignmentEntity } from './commission-assignment.entity'; +import { CommissionPeriodEntity } from './commission-period.entity'; + +export enum EntryStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'commissions', name: 'entries' }) +export class CommissionEntryEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'scheme_id', type: 'uuid' }) + schemeId: string; + + @Column({ name: 'assignment_id', type: 'uuid', nullable: true }) + assignmentId: string; + + @Column({ name: 'reference_type', length: 50 }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid' }) + referenceId: string; + + @Column({ name: 'base_amount', type: 'decimal', precision: 15, scale: 2 }) + baseAmount: number; + + @Column({ name: 'rate_applied', type: 'decimal', precision: 5, scale: 2 }) + rateApplied: number; + + @Column({ name: 'commission_amount', type: 'decimal', precision: 15, scale: 2 }) + commissionAmount: number; + + @Column({ length: 3, default: 'USD' }) + currency: string; + + @Column({ + type: 'enum', + enum: EntryStatus, + enumName: 'entry_status', + default: EntryStatus.PENDING, + }) + status: EntryStatus; + + @Column({ name: 'period_id', type: 'uuid', nullable: true }) + periodId: string; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date; + + @Column({ name: 'payment_reference', length: 255, nullable: true }) + paymentReference: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.entries) + @JoinColumn({ name: 'scheme_id' }) + scheme: CommissionSchemeEntity; + + @ManyToOne(() => CommissionAssignmentEntity) + @JoinColumn({ name: 'assignment_id' }) + assignment: CommissionAssignmentEntity; + + @ManyToOne(() => CommissionPeriodEntity, (period) => period.entries) + @JoinColumn({ name: 'period_id' }) + period: CommissionPeriodEntity; +} diff --git a/src/modules/commissions/entities/commission-period.entity.ts b/src/modules/commissions/entities/commission-period.entity.ts new file mode 100644 index 0000000..b2a06af --- /dev/null +++ b/src/modules/commissions/entities/commission-period.entity.ts @@ -0,0 +1,76 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToMany, +} from 'typeorm'; + +export enum PeriodStatus { + OPEN = 'open', + CLOSED = 'closed', + PROCESSING = 'processing', + PAID = 'paid', +} + +@Entity({ schema: 'commissions', name: 'periods' }) +export class CommissionPeriodEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ name: 'starts_at', type: 'timestamptz' }) + startsAt: Date; + + @Column({ name: 'ends_at', type: 'timestamptz' }) + endsAt: Date; + + @Column({ name: 'total_entries', type: 'int', default: 0 }) + totalEntries: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalAmount: number; + + @Column({ length: 3, default: 'USD' }) + currency: string; + + @Column({ + type: 'enum', + enum: PeriodStatus, + enumName: 'period_status', + default: PeriodStatus.OPEN, + }) + status: PeriodStatus; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt: Date; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedBy: string; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date; + + @Column({ name: 'paid_by', type: 'uuid', nullable: true }) + paidBy: string; + + @Column({ name: 'payment_reference', length: 255, nullable: true }) + paymentReference: string | null; + + @Column({ name: 'payment_notes', type: 'text', nullable: true }) + paymentNotes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @OneToMany('CommissionEntryEntity', 'period') + entries: any[]; +} diff --git a/src/modules/commissions/entities/commission-scheme.entity.ts b/src/modules/commissions/entities/commission-scheme.entity.ts new file mode 100644 index 0000000..b479618 --- /dev/null +++ b/src/modules/commissions/entities/commission-scheme.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; + +export enum SchemeType { + PERCENTAGE = 'percentage', + FIXED = 'fixed', + TIERED = 'tiered', +} + +export enum AppliesTo { + ALL = 'all', + PRODUCTS = 'products', + CATEGORIES = 'categories', +} + +@Entity({ schema: 'commissions', name: 'schemes' }) +export class CommissionSchemeEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: SchemeType, + enumName: 'scheme_type', + default: SchemeType.PERCENTAGE, + }) + type: SchemeType; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + rate: number; + + @Column({ name: 'fixed_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + fixedAmount: number; + + @Column({ type: 'jsonb', default: [] }) + tiers: any[]; + + @Column({ + name: 'applies_to', + type: 'enum', + enum: AppliesTo, + enumName: 'applies_to', + default: AppliesTo.ALL, + }) + appliesTo: AppliesTo; + + @Column({ name: 'product_ids', type: 'uuid', array: true, default: [] }) + productIds: string[]; + + @Column({ name: 'category_ids', type: 'uuid', array: true, default: [] }) + categoryIds: string[]; + + @Column({ name: 'min_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + minAmount: number; + + @Column({ name: 'max_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + maxAmount: number; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @OneToMany('CommissionAssignmentEntity', 'scheme') + assignments: any[]; + + @OneToMany('CommissionEntryEntity', 'scheme') + entries: any[]; +} diff --git a/src/modules/commissions/entities/index.ts b/src/modules/commissions/entities/index.ts new file mode 100644 index 0000000..6915eb6 --- /dev/null +++ b/src/modules/commissions/entities/index.ts @@ -0,0 +1,4 @@ +export * from './commission-scheme.entity'; +export * from './commission-assignment.entity'; +export * from './commission-period.entity'; +export * from './commission-entry.entity'; diff --git a/src/modules/commissions/index.ts b/src/modules/commissions/index.ts new file mode 100644 index 0000000..095440d --- /dev/null +++ b/src/modules/commissions/index.ts @@ -0,0 +1,5 @@ +export * from './commissions.module'; +export * from './entities'; +export * from './dto'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/commissions/services/assignments.service.ts b/src/modules/commissions/services/assignments.service.ts new file mode 100644 index 0000000..40d12b4 --- /dev/null +++ b/src/modules/commissions/services/assignments.service.ts @@ -0,0 +1,172 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { CommissionAssignmentEntity, CommissionSchemeEntity } from '../entities'; +import { + CreateAssignmentDto, + UpdateAssignmentDto, + AssignmentResponseDto, + AssignmentListQueryDto, + PaginatedAssignmentsDto, +} from '../dto'; + +@Injectable() +export class AssignmentsService { + private readonly logger = new Logger(AssignmentsService.name); + + constructor( + @InjectRepository(CommissionAssignmentEntity) + private readonly assignmentRepo: Repository, + @InjectRepository(CommissionSchemeEntity) + private readonly schemeRepo: Repository, + ) {} + + async create(tenantId: string, userId: string, dto: CreateAssignmentDto): Promise { + // Verify scheme exists + const scheme = await this.schemeRepo.findOne({ + where: { id: dto.schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + const assignment = this.assignmentRepo.create({ + tenantId, + userId: dto.userId, + schemeId: dto.schemeId, + startsAt: dto.startsAt ? new Date(dto.startsAt) : new Date(), + endsAt: dto.endsAt ? new Date(dto.endsAt) : undefined, + customRate: dto.customRate, + isActive: dto.isActive ?? true, + createdBy: userId, + }); + + const saved = await this.assignmentRepo.save(assignment); + this.logger.log(`Commission assignment created: ${saved.id} for user ${dto.userId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: AssignmentListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.assignmentRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.scheme', 'scheme') + .where('a.tenant_id = :tenantId', { tenantId }); + + if (query.userId) { + qb.andWhere('a.user_id = :userId', { userId: query.userId }); + } + + if (query.schemeId) { + qb.andWhere('a.scheme_id = :schemeId', { schemeId: query.schemeId }); + } + + if (query.isActive !== undefined) { + qb.andWhere('a.is_active = :isActive', { isActive: query.isActive }); + } + + qb.orderBy('a.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((a) => this.toResponse(a)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, assignmentId: string): Promise { + const assignment = await this.assignmentRepo.findOne({ + where: { id: assignmentId, tenantId }, + relations: ['scheme'], + }); + + if (!assignment) { + throw new NotFoundException('Commission assignment not found'); + } + + return this.toResponse(assignment); + } + + async update( + tenantId: string, + assignmentId: string, + dto: UpdateAssignmentDto, + ): Promise { + const assignment = await this.assignmentRepo.findOne({ + where: { id: assignmentId, tenantId }, + }); + + if (!assignment) { + throw new NotFoundException('Commission assignment not found'); + } + + Object.assign(assignment, { + startsAt: dto.startsAt ? new Date(dto.startsAt) : assignment.startsAt, + endsAt: dto.endsAt ? new Date(dto.endsAt) : assignment.endsAt, + customRate: dto.customRate ?? assignment.customRate, + isActive: dto.isActive ?? assignment.isActive, + }); + + const saved = await this.assignmentRepo.save(assignment); + this.logger.log(`Commission assignment updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, assignmentId: string): Promise { + const assignment = await this.assignmentRepo.findOne({ + where: { id: assignmentId, tenantId }, + }); + + if (!assignment) { + throw new NotFoundException('Commission assignment not found'); + } + + await this.assignmentRepo.remove(assignment); + this.logger.log(`Commission assignment deleted: ${assignmentId}`); + } + + async findActiveForUser(tenantId: string, userId: string): Promise { + const assignments = await this.assignmentRepo.find({ + where: { + tenantId, + userId, + isActive: true, + }, + relations: ['scheme'], + }); + + return assignments + .filter((a) => { + const now = new Date(); + return a.startsAt <= now && (!a.endsAt || a.endsAt > now); + }) + .map((a) => this.toResponse(a)); + } + + private toResponse(assignment: CommissionAssignmentEntity): AssignmentResponseDto { + return { + id: assignment.id, + tenantId: assignment.tenantId, + userId: assignment.userId, + schemeId: assignment.schemeId, + startsAt: assignment.startsAt, + endsAt: assignment.endsAt, + customRate: assignment.customRate ? Number(assignment.customRate) : null, + isActive: assignment.isActive, + createdAt: assignment.createdAt, + createdBy: assignment.createdBy, + scheme: assignment.scheme, + }; + } +} diff --git a/src/modules/commissions/services/commissions-dashboard.service.ts b/src/modules/commissions/services/commissions-dashboard.service.ts new file mode 100644 index 0000000..9a560cf --- /dev/null +++ b/src/modules/commissions/services/commissions-dashboard.service.ts @@ -0,0 +1,183 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { + CommissionSchemeEntity, + CommissionAssignmentEntity, + CommissionEntryEntity, + CommissionPeriodEntity, + EntryStatus, + PeriodStatus, +} from '../entities'; +import { + CommissionsDashboardDto, + UserEarningsDto, + EntriesByStatusDto, + EntriesBySchemeDto, + EntriesByUserDto, +} from '../dto'; + +@Injectable() +export class CommissionsDashboardService { + private readonly logger = new Logger(CommissionsDashboardService.name); + + constructor( + @InjectRepository(CommissionSchemeEntity) + private readonly schemeRepo: Repository, + @InjectRepository(CommissionAssignmentEntity) + private readonly assignmentRepo: Repository, + @InjectRepository(CommissionEntryEntity) + private readonly entryRepo: Repository, + @InjectRepository(CommissionPeriodEntity) + private readonly periodRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async getDashboardSummary(tenantId: string): Promise { + // Total schemes + const totalSchemes = await this.schemeRepo.count({ + where: { tenantId, isActive: true, deletedAt: null as any }, + }); + + // Total active assignments + const totalActiveAssignments = await this.assignmentRepo.count({ + where: { tenantId, isActive: true }, + }); + + // Entries by status + const entriesStats = await this.entryRepo + .createQueryBuilder('e') + .select('e.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .addSelect('COALESCE(SUM(e.commission_amount), 0)', 'total') + .where('e.tenant_id = :tenantId', { tenantId }) + .groupBy('e.status') + .getRawMany(); + + const statsMap = new Map(entriesStats.map((s) => [s.status, s])); + + const pending = statsMap.get(EntryStatus.PENDING) || { count: 0, total: 0 }; + const approved = statsMap.get(EntryStatus.APPROVED) || { count: 0, total: 0 }; + const paid = statsMap.get(EntryStatus.PAID) || { count: 0, total: 0 }; + + // Current period + const currentPeriod = await this.periodRepo.findOne({ + where: { tenantId, status: PeriodStatus.OPEN }, + order: { startsAt: 'DESC' }, + }); + + return { + totalSchemes, + totalActiveAssignments, + pendingCommissions: pending.count, + pendingAmount: Number(pending.total), + approvedAmount: Number(approved.total), + paidAmount: Number(paid.total), + currentPeriod: currentPeriod?.name || null, + currency: 'USD', + }; + } + + async getUserEarnings( + tenantId: string, + userId: string, + startDate?: Date, + endDate?: Date, + ): Promise { + const result = await this.dataSource.query( + `SELECT * FROM commissions.get_user_earnings($1, $2, $3, $4)`, + [userId, tenantId, startDate || null, endDate || null], + ); + + const earnings = result[0] || { + total_pending: 0, + total_approved: 0, + total_paid: 0, + total_entries: 0, + currency: 'USD', + }; + + return { + totalPending: Number(earnings.total_pending), + totalApproved: Number(earnings.total_approved), + totalPaid: Number(earnings.total_paid), + totalEntries: Number(earnings.total_entries), + currency: earnings.currency, + }; + } + + async getEntriesByStatus(tenantId: string): Promise { + const result = await this.entryRepo + .createQueryBuilder('e') + .select('e.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount') + .where('e.tenant_id = :tenantId', { tenantId }) + .groupBy('e.status') + .getRawMany(); + + const total = result.reduce((sum, r) => sum + r.count, 0); + + return result.map((r) => ({ + status: r.status, + count: r.count, + totalAmount: Number(r.totalAmount), + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getEntriesByScheme(tenantId: string): Promise { + const result = await this.entryRepo + .createQueryBuilder('e') + .innerJoin('e.scheme', 's') + .select('e.scheme_id', 'schemeId') + .addSelect('s.name', 'schemeName') + .addSelect('COUNT(*)::int', 'count') + .addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount') + .where('e.tenant_id = :tenantId', { tenantId }) + .groupBy('e.scheme_id') + .addGroupBy('s.name') + .getRawMany(); + + const total = result.reduce((sum, r) => sum + r.count, 0); + + return result.map((r) => ({ + schemeId: r.schemeId, + schemeName: r.schemeName, + count: r.count, + totalAmount: Number(r.totalAmount), + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getEntriesByUser(tenantId: string): Promise { + const result = await this.dataSource.query(` + SELECT + e.user_id as "userId", + COALESCE(u.first_name || ' ' || u.last_name, u.email) as "userName", + COUNT(*)::int as count, + COALESCE(SUM(e.commission_amount), 0) as "totalAmount" + FROM commissions.entries e + LEFT JOIN users.users u ON e.user_id = u.id + WHERE e.tenant_id = $1 + GROUP BY e.user_id, u.first_name, u.last_name, u.email + ORDER BY "totalAmount" DESC + `, [tenantId]); + + const total = result.reduce((sum: number, r: any) => sum + r.count, 0); + + return result.map((r: any) => ({ + userId: r.userId, + userName: r.userName, + count: r.count, + totalAmount: Number(r.totalAmount), + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getTopEarners(tenantId: string, limit: number = 10): Promise { + const users = await this.getEntriesByUser(tenantId); + return users.slice(0, limit); + } +} diff --git a/src/modules/commissions/services/entries.service.ts b/src/modules/commissions/services/entries.service.ts new file mode 100644 index 0000000..cb6ef36 --- /dev/null +++ b/src/modules/commissions/services/entries.service.ts @@ -0,0 +1,288 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { CommissionEntryEntity, EntryStatus } from '../entities'; +import { + CreateEntryDto, + UpdateEntryDto, + ApproveEntryDto, + RejectEntryDto, + EntryResponseDto, + EntryListQueryDto, + PaginatedEntriesDto, + CalculateCommissionDto, + CalculateCommissionResponseDto, +} from '../dto'; + +@Injectable() +export class EntriesService { + private readonly logger = new Logger(EntriesService.name); + + constructor( + @InjectRepository(CommissionEntryEntity) + private readonly entryRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(tenantId: string, userId: string, dto: CreateEntryDto): Promise { + // Calculate commission using database function + const calcResult = await this.dataSource.query( + `SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`, + [dto.schemeId, dto.userId, dto.baseAmount, tenantId], + ); + + const { rate_applied, commission_amount } = calcResult[0] || { rate_applied: 0, commission_amount: 0 }; + + if (commission_amount === 0) { + throw new BadRequestException('Commission calculation resulted in zero amount. Check scheme configuration.'); + } + + const entry = this.entryRepo.create({ + tenantId, + userId: dto.userId, + schemeId: dto.schemeId, + assignmentId: dto.assignmentId, + referenceType: dto.referenceType, + referenceId: dto.referenceId, + baseAmount: dto.baseAmount, + rateApplied: rate_applied, + commissionAmount: commission_amount, + currency: dto.currency ?? 'USD', + notes: dto.notes, + metadata: dto.metadata ?? {}, + }); + + const saved = await this.entryRepo.save(entry); + this.logger.log(`Commission entry created: ${saved.id} for user ${dto.userId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: EntryListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.entryRepo + .createQueryBuilder('e') + .leftJoinAndSelect('e.scheme', 'scheme') + .leftJoinAndSelect('e.period', 'period') + .where('e.tenant_id = :tenantId', { tenantId }); + + if (query.userId) { + qb.andWhere('e.user_id = :userId', { userId: query.userId }); + } + + if (query.schemeId) { + qb.andWhere('e.scheme_id = :schemeId', { schemeId: query.schemeId }); + } + + if (query.periodId) { + qb.andWhere('e.period_id = :periodId', { periodId: query.periodId }); + } + + if (query.status) { + qb.andWhere('e.status = :status', { status: query.status }); + } + + if (query.referenceType) { + qb.andWhere('e.reference_type = :referenceType', { referenceType: query.referenceType }); + } + + qb.orderBy('e.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((e) => this.toResponse(e)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, entryId: string): Promise { + const entry = await this.entryRepo.findOne({ + where: { id: entryId, tenantId }, + relations: ['scheme', 'period'], + }); + + if (!entry) { + throw new NotFoundException('Commission entry not found'); + } + + return this.toResponse(entry); + } + + async update( + tenantId: string, + entryId: string, + dto: UpdateEntryDto, + ): Promise { + const entry = await this.entryRepo.findOne({ + where: { id: entryId, tenantId }, + }); + + if (!entry) { + throw new NotFoundException('Commission entry not found'); + } + + Object.assign(entry, { + status: dto.status ?? entry.status, + periodId: dto.periodId ?? entry.periodId, + notes: dto.notes ?? entry.notes, + metadata: dto.metadata ?? entry.metadata, + }); + + const saved = await this.entryRepo.save(entry); + this.logger.log(`Commission entry updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async approve( + tenantId: string, + entryId: string, + approvedBy: string, + dto: ApproveEntryDto, + ): Promise { + const entry = await this.entryRepo.findOne({ + where: { id: entryId, tenantId }, + }); + + if (!entry) { + throw new NotFoundException('Commission entry not found'); + } + + if (entry.status !== EntryStatus.PENDING) { + throw new BadRequestException('Only pending entries can be approved'); + } + + entry.status = EntryStatus.APPROVED; + entry.approvedBy = approvedBy; + entry.approvedAt = new Date(); + entry.notes = dto.notes ?? entry.notes; + + const saved = await this.entryRepo.save(entry); + this.logger.log(`Commission entry approved: ${saved.id}`); + + return this.toResponse(saved); + } + + async reject( + tenantId: string, + entryId: string, + rejectedBy: string, + dto: RejectEntryDto, + ): Promise { + const entry = await this.entryRepo.findOne({ + where: { id: entryId, tenantId }, + }); + + if (!entry) { + throw new NotFoundException('Commission entry not found'); + } + + if (entry.status !== EntryStatus.PENDING) { + throw new BadRequestException('Only pending entries can be rejected'); + } + + entry.status = EntryStatus.REJECTED; + entry.approvedBy = rejectedBy; + entry.approvedAt = new Date(); + entry.notes = dto.notes; + + const saved = await this.entryRepo.save(entry); + this.logger.log(`Commission entry rejected: ${saved.id}`); + + return this.toResponse(saved); + } + + async calculateCommission( + tenantId: string, + dto: CalculateCommissionDto, + ): Promise { + const result = await this.dataSource.query( + `SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`, + [dto.schemeId, dto.userId, dto.amount, tenantId], + ); + + const { rate_applied, commission_amount } = result[0] || { rate_applied: 0, commission_amount: 0 }; + + return { + rateApplied: Number(rate_applied), + commissionAmount: Number(commission_amount), + }; + } + + async markAsPaid(tenantId: string, entryId: string, paymentReference: string): Promise { + const entry = await this.entryRepo.findOne({ + where: { id: entryId, tenantId }, + }); + + if (!entry) { + throw new NotFoundException('Commission entry not found'); + } + + if (entry.status !== EntryStatus.APPROVED) { + throw new BadRequestException('Only approved entries can be marked as paid'); + } + + entry.status = EntryStatus.PAID; + entry.paidAt = new Date(); + entry.paymentReference = paymentReference; + + const saved = await this.entryRepo.save(entry); + this.logger.log(`Commission entry marked as paid: ${saved.id}`); + + return this.toResponse(saved); + } + + async bulkApprove(tenantId: string, entryIds: string[], approvedBy: string): Promise { + const result = await this.entryRepo + .createQueryBuilder() + .update() + .set({ + status: EntryStatus.APPROVED, + approvedBy, + approvedAt: new Date(), + }) + .where('id IN (:...entryIds)', { entryIds }) + .andWhere('tenant_id = :tenantId', { tenantId }) + .andWhere('status = :status', { status: EntryStatus.PENDING }) + .execute(); + + this.logger.log(`Bulk approved ${result.affected} commission entries`); + return result.affected || 0; + } + + private toResponse(entry: CommissionEntryEntity): EntryResponseDto { + return { + id: entry.id, + tenantId: entry.tenantId, + userId: entry.userId, + schemeId: entry.schemeId, + assignmentId: entry.assignmentId, + referenceType: entry.referenceType, + referenceId: entry.referenceId, + baseAmount: Number(entry.baseAmount), + rateApplied: Number(entry.rateApplied), + commissionAmount: Number(entry.commissionAmount), + currency: entry.currency, + status: entry.status, + periodId: entry.periodId, + paidAt: entry.paidAt, + paymentReference: entry.paymentReference, + notes: entry.notes, + metadata: entry.metadata, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + approvedBy: entry.approvedBy, + approvedAt: entry.approvedAt, + scheme: entry.scheme, + period: entry.period, + }; + } +} diff --git a/src/modules/commissions/services/index.ts b/src/modules/commissions/services/index.ts new file mode 100644 index 0000000..6d48e80 --- /dev/null +++ b/src/modules/commissions/services/index.ts @@ -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'; diff --git a/src/modules/commissions/services/periods.service.ts b/src/modules/commissions/services/periods.service.ts new file mode 100644 index 0000000..05e019d --- /dev/null +++ b/src/modules/commissions/services/periods.service.ts @@ -0,0 +1,263 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { CommissionPeriodEntity, PeriodStatus, CommissionEntryEntity, EntryStatus } from '../entities'; +import { + CreatePeriodDto, + UpdatePeriodDto, + ClosePeriodDto, + MarkPaidDto, + PeriodResponseDto, + PeriodListQueryDto, + PaginatedPeriodsDto, +} from '../dto'; + +@Injectable() +export class PeriodsService { + private readonly logger = new Logger(PeriodsService.name); + + constructor( + @InjectRepository(CommissionPeriodEntity) + private readonly periodRepo: Repository, + @InjectRepository(CommissionEntryEntity) + private readonly entryRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(tenantId: string, userId: string, dto: CreatePeriodDto): Promise { + const period = this.periodRepo.create({ + tenantId, + name: dto.name, + startsAt: new Date(dto.startsAt), + endsAt: new Date(dto.endsAt), + currency: dto.currency ?? 'USD', + createdBy: userId, + }); + + const saved = await this.periodRepo.save(period); + this.logger.log(`Commission period created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: PeriodListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.periodRepo + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId }); + + if (query.status) { + qb.andWhere('p.status = :status', { status: query.status }); + } + + qb.orderBy('p.starts_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((p) => this.toResponse(p)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, periodId: string): Promise { + const period = await this.periodRepo.findOne({ + where: { id: periodId, tenantId }, + }); + + if (!period) { + throw new NotFoundException('Commission period not found'); + } + + return this.toResponse(period); + } + + async update( + tenantId: string, + periodId: string, + dto: UpdatePeriodDto, + ): Promise { + const period = await this.periodRepo.findOne({ + where: { id: periodId, tenantId }, + }); + + if (!period) { + throw new NotFoundException('Commission period not found'); + } + + if (period.status !== PeriodStatus.OPEN) { + throw new BadRequestException('Only open periods can be updated'); + } + + Object.assign(period, { + name: dto.name ?? period.name, + startsAt: dto.startsAt ? new Date(dto.startsAt) : period.startsAt, + endsAt: dto.endsAt ? new Date(dto.endsAt) : period.endsAt, + }); + + const saved = await this.periodRepo.save(period); + this.logger.log(`Commission period updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async close( + tenantId: string, + periodId: string, + userId: string, + dto: ClosePeriodDto, + ): Promise { + const period = await this.periodRepo.findOne({ + where: { id: periodId, tenantId }, + }); + + if (!period) { + throw new NotFoundException('Commission period not found'); + } + + if (period.status !== PeriodStatus.OPEN) { + throw new BadRequestException('Only open periods can be closed'); + } + + // Use database function to close period + await this.dataSource.query( + `SELECT commissions.close_period($1, $2)`, + [periodId, userId], + ); + + // Reload the period + const updated = await this.periodRepo.findOne({ where: { id: periodId } }); + this.logger.log(`Commission period closed: ${periodId}`); + + return this.toResponse(updated!); + } + + async markAsPaid( + tenantId: string, + periodId: string, + userId: string, + dto: MarkPaidDto, + ): Promise { + const period = await this.periodRepo.findOne({ + where: { id: periodId, tenantId }, + }); + + if (!period) { + throw new NotFoundException('Commission period not found'); + } + + if (period.status !== PeriodStatus.CLOSED && period.status !== PeriodStatus.PROCESSING) { + throw new BadRequestException('Only closed or processing periods can be marked as paid'); + } + + // Mark all approved entries in this period as paid + await this.entryRepo + .createQueryBuilder() + .update() + .set({ + status: EntryStatus.PAID, + paidAt: new Date(), + paymentReference: dto.paymentReference, + }) + .where('period_id = :periodId', { periodId }) + .andWhere('status = :status', { status: EntryStatus.APPROVED }) + .execute(); + + // Update period status + period.status = PeriodStatus.PAID; + period.paidAt = new Date(); + period.paidBy = userId; + period.paymentReference = dto.paymentReference ?? null; + period.paymentNotes = dto.paymentNotes ?? null; + + const saved = await this.periodRepo.save(period); + this.logger.log(`Commission period marked as paid: ${periodId}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, periodId: string): Promise { + const period = await this.periodRepo.findOne({ + where: { id: periodId, tenantId }, + }); + + if (!period) { + throw new NotFoundException('Commission period not found'); + } + + if (period.status !== PeriodStatus.OPEN) { + throw new BadRequestException('Only open periods can be deleted'); + } + + // Check if there are entries in this period + const entriesCount = await this.entryRepo.count({ + where: { periodId, tenantId }, + }); + + if (entriesCount > 0) { + throw new BadRequestException(`Cannot delete period with ${entriesCount} entries`); + } + + await this.periodRepo.remove(period); + this.logger.log(`Commission period deleted: ${periodId}`); + } + + async getCurrentPeriod(tenantId: string): Promise { + const now = new Date(); + const period = await this.periodRepo.findOne({ + where: { + tenantId, + status: PeriodStatus.OPEN, + }, + order: { startsAt: 'DESC' }, + }); + + return period ? this.toResponse(period) : null; + } + + async assignEntriesToPeriod( + tenantId: string, + periodId: string, + entryIds: string[], + ): Promise { + const result = await this.entryRepo + .createQueryBuilder() + .update() + .set({ periodId }) + .where('id IN (:...entryIds)', { entryIds }) + .andWhere('tenant_id = :tenantId', { tenantId }) + .andWhere('period_id IS NULL') + .execute(); + + return result.affected || 0; + } + + private toResponse(period: CommissionPeriodEntity): PeriodResponseDto { + return { + id: period.id, + tenantId: period.tenantId, + name: period.name, + startsAt: period.startsAt, + endsAt: period.endsAt, + totalEntries: period.totalEntries, + totalAmount: Number(period.totalAmount), + currency: period.currency, + status: period.status, + closedAt: period.closedAt, + closedBy: period.closedBy, + paidAt: period.paidAt, + paidBy: period.paidBy, + paymentReference: period.paymentReference, + paymentNotes: period.paymentNotes, + createdAt: period.createdAt, + createdBy: period.createdBy, + }; + } +} diff --git a/src/modules/commissions/services/schemes.service.ts b/src/modules/commissions/services/schemes.service.ts new file mode 100644 index 0000000..64917e3 --- /dev/null +++ b/src/modules/commissions/services/schemes.service.ts @@ -0,0 +1,195 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { CommissionSchemeEntity } from '../entities'; +import { + CreateSchemeDto, + UpdateSchemeDto, + SchemeResponseDto, + SchemeListQueryDto, + PaginatedSchemesDto, +} from '../dto'; + +@Injectable() +export class SchemesService { + private readonly logger = new Logger(SchemesService.name); + + constructor( + @InjectRepository(CommissionSchemeEntity) + private readonly schemeRepo: Repository, + ) {} + + async create(tenantId: string, userId: string, dto: CreateSchemeDto): Promise { + const scheme = this.schemeRepo.create({ + tenantId, + name: dto.name, + description: dto.description, + type: dto.type, + rate: dto.rate ?? 0, + fixedAmount: dto.fixedAmount ?? 0, + tiers: dto.tiers ?? [], + appliesTo: dto.appliesTo, + productIds: dto.productIds ?? [], + categoryIds: dto.categoryIds ?? [], + minAmount: dto.minAmount ?? 0, + maxAmount: dto.maxAmount, + isActive: dto.isActive ?? true, + createdBy: userId, + }); + + const saved = await this.schemeRepo.save(scheme); + this.logger.log(`Commission scheme created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: SchemeListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.schemeRepo + .createQueryBuilder('s') + .where('s.tenant_id = :tenantId', { tenantId }) + .andWhere('s.deleted_at IS NULL'); + + if (query.type) { + qb.andWhere('s.type = :type', { type: query.type }); + } + + if (query.isActive !== undefined) { + qb.andWhere('s.is_active = :isActive', { isActive: query.isActive }); + } + + if (query.search) { + qb.andWhere('(s.name ILIKE :search OR s.description ILIKE :search)', { + search: `%${query.search}%`, + }); + } + + qb.orderBy('s.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((s) => this.toResponse(s)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, schemeId: string): Promise { + const scheme = await this.schemeRepo.findOne({ + where: { id: schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + return this.toResponse(scheme); + } + + async update( + tenantId: string, + schemeId: string, + dto: UpdateSchemeDto, + ): Promise { + const scheme = await this.schemeRepo.findOne({ + where: { id: schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + Object.assign(scheme, { + name: dto.name ?? scheme.name, + description: dto.description ?? scheme.description, + type: dto.type ?? scheme.type, + rate: dto.rate ?? scheme.rate, + fixedAmount: dto.fixedAmount ?? scheme.fixedAmount, + tiers: dto.tiers ?? scheme.tiers, + appliesTo: dto.appliesTo ?? scheme.appliesTo, + productIds: dto.productIds ?? scheme.productIds, + categoryIds: dto.categoryIds ?? scheme.categoryIds, + minAmount: dto.minAmount ?? scheme.minAmount, + maxAmount: dto.maxAmount ?? scheme.maxAmount, + isActive: dto.isActive ?? scheme.isActive, + }); + + const saved = await this.schemeRepo.save(scheme); + this.logger.log(`Commission scheme updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, schemeId: string): Promise { + const scheme = await this.schemeRepo.findOne({ + where: { id: schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + scheme.deletedAt = new Date(); + await this.schemeRepo.save(scheme); + this.logger.log(`Commission scheme soft-deleted: ${schemeId}`); + } + + async activate(tenantId: string, schemeId: string): Promise { + const scheme = await this.schemeRepo.findOne({ + where: { id: schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + scheme.isActive = true; + const saved = await this.schemeRepo.save(scheme); + + return this.toResponse(saved); + } + + async deactivate(tenantId: string, schemeId: string): Promise { + const scheme = await this.schemeRepo.findOne({ + where: { id: schemeId, tenantId, deletedAt: null as any }, + }); + + if (!scheme) { + throw new NotFoundException('Commission scheme not found'); + } + + scheme.isActive = false; + const saved = await this.schemeRepo.save(scheme); + + return this.toResponse(saved); + } + + private toResponse(scheme: CommissionSchemeEntity): SchemeResponseDto { + return { + id: scheme.id, + tenantId: scheme.tenantId, + name: scheme.name, + description: scheme.description, + type: scheme.type, + rate: Number(scheme.rate), + fixedAmount: Number(scheme.fixedAmount), + tiers: scheme.tiers, + appliesTo: scheme.appliesTo, + productIds: scheme.productIds, + categoryIds: scheme.categoryIds, + minAmount: Number(scheme.minAmount), + maxAmount: scheme.maxAmount ? Number(scheme.maxAmount) : null, + isActive: scheme.isActive, + createdAt: scheme.createdAt, + updatedAt: scheme.updatedAt, + createdBy: scheme.createdBy, + }; + } +} diff --git a/src/modules/sales/controllers/activities.controller.ts b/src/modules/sales/controllers/activities.controller.ts new file mode 100644 index 0000000..07fbe9d --- /dev/null +++ b/src/modules/sales/controllers/activities.controller.ts @@ -0,0 +1,92 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { ActivitiesService } from '../services'; +import { + CreateActivityDto, + UpdateActivityDto, + CompleteActivityDto, + ActivityListQueryDto, + ActivityResponseDto, + PaginatedActivitiesDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('sales/activities') +@UseGuards(JwtAuthGuard) +export class ActivitiesController { + constructor(private readonly activitiesService: ActivitiesService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: ActivityListQueryDto, + ): Promise { + return this.activitiesService.findAll(user.tenant_id, query); + } + + @Get('upcoming') + async getUpcoming(@CurrentUser() user: RequestUser): Promise { + return this.activitiesService.getUpcoming(user.tenant_id, user.id); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.activitiesService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateActivityDto, + ): Promise { + return this.activitiesService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateActivityDto, + ): Promise { + return this.activitiesService.update(user.tenant_id, id, dto); + } + + @Post(':id/complete') + async complete( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CompleteActivityDto, + ): Promise { + return this.activitiesService.complete(user.tenant_id, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.activitiesService.remove(user.tenant_id, id); + return { message: 'Activity deleted successfully' }; + } +} diff --git a/src/modules/sales/controllers/dashboard.controller.ts b/src/modules/sales/controllers/dashboard.controller.ts new file mode 100644 index 0000000..2640db6 --- /dev/null +++ b/src/modules/sales/controllers/dashboard.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { SalesDashboardService } from '../services'; +import { + SalesDashboardDto, + LeadsByStatusDto, + LeadsBySourceDto, + OpportunitiesByStageDto, + ConversionFunnelDto, + SalesPerformanceDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('sales/dashboard') +@UseGuards(JwtAuthGuard) +export class SalesDashboardController { + constructor(private readonly dashboardService: SalesDashboardService) {} + + @Get() + async getSummary(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getDashboardSummary(user.tenant_id); + } + + @Get('leads-by-status') + async getLeadsByStatus(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getLeadsByStatus(user.tenant_id); + } + + @Get('leads-by-source') + async getLeadsBySource(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getLeadsBySource(user.tenant_id); + } + + @Get('opportunities-by-stage') + async getOpportunitiesByStage( + @CurrentUser() user: RequestUser, + ): Promise { + return this.dashboardService.getOpportunitiesByStage(user.tenant_id); + } + + @Get('conversion-funnel') + async getConversionFunnel(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getConversionFunnel(user.tenant_id); + } + + @Get('sales-performance') + async getSalesPerformance(@CurrentUser() user: RequestUser): Promise { + return this.dashboardService.getSalesPerformance(user.tenant_id); + } +} diff --git a/src/modules/sales/controllers/index.ts b/src/modules/sales/controllers/index.ts new file mode 100644 index 0000000..d553c49 --- /dev/null +++ b/src/modules/sales/controllers/index.ts @@ -0,0 +1,5 @@ +export * from './leads.controller'; +export * from './opportunities.controller'; +export * from './activities.controller'; +export * from './pipeline.controller'; +export * from './dashboard.controller'; diff --git a/src/modules/sales/controllers/leads.controller.ts b/src/modules/sales/controllers/leads.controller.ts new file mode 100644 index 0000000..cd0c7ab --- /dev/null +++ b/src/modules/sales/controllers/leads.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { LeadsService } from '../services'; +import { + CreateLeadDto, + UpdateLeadDto, + ConvertLeadDto, + LeadListQueryDto, + LeadResponseDto, + PaginatedLeadsDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('sales/leads') +@UseGuards(JwtAuthGuard) +export class LeadsController { + constructor(private readonly leadsService: LeadsService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: LeadListQueryDto, + ): Promise { + return this.leadsService.findAll(user.tenant_id, query); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.leadsService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateLeadDto, + ): Promise { + return this.leadsService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateLeadDto, + ): Promise { + return this.leadsService.update(user.tenant_id, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.leadsService.remove(user.tenant_id, id); + return { message: 'Lead deleted successfully' }; + } + + @Post(':id/convert') + async convert( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ConvertLeadDto, + ): Promise<{ opportunityId: string }> { + return this.leadsService.convert(user.tenant_id, id, dto); + } + + @Post(':id/calculate-score') + async calculateScore( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ score: number }> { + return this.leadsService.calculateScore(user.tenant_id, id); + } + + @Put(':id/assign/:userId') + async assign( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise { + return this.leadsService.assignTo(user.tenant_id, id, userId); + } +} diff --git a/src/modules/sales/controllers/opportunities.controller.ts b/src/modules/sales/controllers/opportunities.controller.ts new file mode 100644 index 0000000..4b33a96 --- /dev/null +++ b/src/modules/sales/controllers/opportunities.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { OpportunitiesService } from '../services'; +import { + CreateOpportunityDto, + UpdateOpportunityDto, + UpdateOpportunityStageDto, + OpportunityListQueryDto, + OpportunityResponseDto, + PaginatedOpportunitiesDto, + PipelineSummaryDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('sales/opportunities') +@UseGuards(JwtAuthGuard) +export class OpportunitiesController { + constructor(private readonly opportunitiesService: OpportunitiesService) {} + + @Get() + async findAll( + @CurrentUser() user: RequestUser, + @Query() query: OpportunityListQueryDto, + ): Promise { + return this.opportunitiesService.findAll(user.tenant_id, query); + } + + @Get('pipeline-summary') + async getPipelineSummary(@CurrentUser() user: RequestUser): Promise { + return this.opportunitiesService.getPipelineSummary(user.tenant_id); + } + + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.opportunitiesService.findOne(user.tenant_id, id); + } + + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateOpportunityDto, + ): Promise { + return this.opportunitiesService.create(user.tenant_id, user.id, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOpportunityDto, + ): Promise { + return this.opportunitiesService.update(user.tenant_id, id, dto); + } + + @Put(':id/stage') + async updateStage( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOpportunityStageDto, + ): Promise { + return this.opportunitiesService.updateStage(user.tenant_id, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.opportunitiesService.remove(user.tenant_id, id); + return { message: 'Opportunity deleted successfully' }; + } + + @Put(':id/assign/:userId') + async assign( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise { + return this.opportunitiesService.assignTo(user.tenant_id, id, userId); + } +} diff --git a/src/modules/sales/controllers/pipeline.controller.ts b/src/modules/sales/controllers/pipeline.controller.ts new file mode 100644 index 0000000..f152392 --- /dev/null +++ b/src/modules/sales/controllers/pipeline.controller.ts @@ -0,0 +1,86 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { PipelineService } from '../services'; +import { + CreatePipelineStageDto, + UpdatePipelineStageDto, + ReorderStagesDto, + PipelineStageResponseDto, +} from '../dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('sales/pipeline') +@UseGuards(JwtAuthGuard) +export class PipelineController { + constructor(private readonly pipelineService: PipelineService) {} + + @Get('stages') + async findAll(@CurrentUser() user: RequestUser): Promise { + return this.pipelineService.findAll(user.tenant_id); + } + + @Get('stages/:id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.pipelineService.findOne(user.tenant_id, id); + } + + @Post('stages') + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreatePipelineStageDto, + ): Promise { + return this.pipelineService.create(user.tenant_id, dto); + } + + @Put('stages/:id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePipelineStageDto, + ): Promise { + return this.pipelineService.update(user.tenant_id, id, dto); + } + + @Delete('stages/:id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.pipelineService.remove(user.tenant_id, id); + return { message: 'Pipeline stage deleted successfully' }; + } + + @Post('stages/reorder') + async reorder( + @CurrentUser() user: RequestUser, + @Body() dto: ReorderStagesDto, + ): Promise { + return this.pipelineService.reorder(user.tenant_id, dto); + } + + @Post('initialize-defaults') + async initializeDefaults(@CurrentUser() user: RequestUser): Promise<{ message: string }> { + await this.pipelineService.initializeDefaults(user.tenant_id); + return { message: 'Default pipeline stages initialized' }; + } +} diff --git a/src/modules/sales/dto/activity.dto.ts b/src/modules/sales/dto/activity.dto.ts new file mode 100644 index 0000000..5b4e227 --- /dev/null +++ b/src/modules/sales/dto/activity.dto.ts @@ -0,0 +1,232 @@ +import { + IsString, + IsOptional, + IsEnum, + IsInt, + IsUUID, + IsObject, + IsArray, + MaxLength, + Min, + IsBoolean, + IsDateString, +} from 'class-validator'; +import { ActivityType, ActivityStatus } from '../entities'; + +export class CreateActivityDto { + @IsEnum(ActivityType) + type: ActivityType; + + @IsString() + @MaxLength(255) + subject: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID() + @IsOptional() + leadId?: string; + + @IsUUID() + @IsOptional() + opportunityId?: string; + + @IsDateString() + @IsOptional() + dueDate?: string; + + @IsString() + @IsOptional() + dueTime?: string; + + @IsInt() + @Min(0) + @IsOptional() + durationMinutes?: number; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @MaxLength(10) + @IsOptional() + callDirection?: string; + + @IsString() + @MaxLength(500) + @IsOptional() + callRecordingUrl?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + location?: string; + + @IsString() + @MaxLength(500) + @IsOptional() + meetingUrl?: string; + + @IsArray() + @IsOptional() + attendees?: any[]; + + @IsDateString() + @IsOptional() + reminderAt?: string; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class UpdateActivityDto { + @IsEnum(ActivityType) + @IsOptional() + type?: ActivityType; + + @IsEnum(ActivityStatus) + @IsOptional() + status?: ActivityStatus; + + @IsString() + @MaxLength(255) + @IsOptional() + subject?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsDateString() + @IsOptional() + dueDate?: string; + + @IsString() + @IsOptional() + dueTime?: string; + + @IsInt() + @Min(0) + @IsOptional() + durationMinutes?: number; + + @IsString() + @IsOptional() + outcome?: string; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @MaxLength(10) + @IsOptional() + callDirection?: string; + + @IsString() + @MaxLength(500) + @IsOptional() + callRecordingUrl?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + location?: string; + + @IsString() + @MaxLength(500) + @IsOptional() + meetingUrl?: string; + + @IsArray() + @IsOptional() + attendees?: any[]; + + @IsDateString() + @IsOptional() + reminderAt?: string; + + @IsBoolean() + @IsOptional() + reminderSent?: boolean; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class CompleteActivityDto { + @IsString() + @IsOptional() + outcome?: string; +} + +export class ActivityResponseDto { + id: string; + tenantId: string; + type: ActivityType; + status: ActivityStatus; + subject: string; + description: string | null; + leadId: string | null; + opportunityId: string | null; + dueDate: Date | null; + dueTime: string | null; + durationMinutes: number | null; + completedAt: Date | null; + outcome: string | null; + assignedTo: string | null; + createdBy: string | null; + callDirection: string | null; + callRecordingUrl: string | null; + location: string | null; + meetingUrl: string | null; + attendees: any[]; + reminderAt: Date | null; + reminderSent: boolean; + customFields: Record; + createdAt: Date; + updatedAt: Date; + lead?: any; + opportunity?: any; +} + +export class ActivityListQueryDto { + @IsEnum(ActivityType) + @IsOptional() + type?: ActivityType; + + @IsEnum(ActivityStatus) + @IsOptional() + status?: ActivityStatus; + + @IsUUID() + @IsOptional() + leadId?: string; + + @IsUUID() + @IsOptional() + opportunityId?: string; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedActivitiesDto { + items: ActivityResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/src/modules/sales/dto/dashboard.dto.ts b/src/modules/sales/dto/dashboard.dto.ts new file mode 100644 index 0000000..f20b8ad --- /dev/null +++ b/src/modules/sales/dto/dashboard.dto.ts @@ -0,0 +1,57 @@ +import { OpportunityStage, LeadStatus, LeadSource } from '../entities'; + +export class SalesDashboardDto { + totalLeads: number; + totalOpportunities: number; + totalPipelineValue: number; + wonDeals: number; + conversionRate: number; + averageDealSize: number; + activitiesThisWeek: number; + currency: string; +} + +export class LeadsByStatusDto { + status: LeadStatus; + count: number; + percentage: number; +} + +export class LeadsBySourceDto { + source: LeadSource; + count: number; + percentage: number; +} + +export class OpportunitiesByStageDto { + stage: OpportunityStage; + count: number; + totalAmount: number; + percentage: number; +} + +export class ConversionFunnelDto { + stage: string; + count: number; + percentage: number; + conversionFromPrevious: number; +} + +export class SalesPerformanceDto { + userId: string; + userName: string; + leadsAssigned: number; + opportunitiesWon: number; + totalRevenue: number; + conversionRate: number; + activitiesCompleted: number; +} + +export class TimelineActivityDto { + date: string; + leadsCreated: number; + opportunitiesCreated: number; + activitiesCompleted: number; + dealsWon: number; + dealsLost: number; +} diff --git a/src/modules/sales/dto/index.ts b/src/modules/sales/dto/index.ts new file mode 100644 index 0000000..269a80e --- /dev/null +++ b/src/modules/sales/dto/index.ts @@ -0,0 +1,5 @@ +export * from './lead.dto'; +export * from './opportunity.dto'; +export * from './activity.dto'; +export * from './pipeline.dto'; +export * from './dashboard.dto'; diff --git a/src/modules/sales/dto/lead.dto.ts b/src/modules/sales/dto/lead.dto.ts new file mode 100644 index 0000000..fa6d7b9 --- /dev/null +++ b/src/modules/sales/dto/lead.dto.ts @@ -0,0 +1,273 @@ +import { + IsString, + IsEmail, + IsOptional, + IsEnum, + IsInt, + IsUUID, + IsObject, + MaxLength, + Min, + Max, + IsNumber, +} from 'class-validator'; +import { LeadStatus, LeadSource } from '../entities'; + +export class CreateLeadDto { + @IsString() + @MaxLength(100) + firstName: string; + + @IsString() + @MaxLength(100) + lastName: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @MaxLength(50) + @IsOptional() + phone?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + company?: string; + + @IsString() + @MaxLength(150) + @IsOptional() + jobTitle?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + website?: string; + + @IsEnum(LeadSource) + @IsOptional() + source?: LeadSource; + + @IsEnum(LeadStatus) + @IsOptional() + status?: LeadStatus; + + @IsInt() + @Min(0) + @Max(100) + @IsOptional() + score?: number; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + addressLine1?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + addressLine2?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + city?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + state?: string; + + @IsString() + @MaxLength(20) + @IsOptional() + postalCode?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + country?: string; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class UpdateLeadDto { + @IsString() + @MaxLength(100) + @IsOptional() + firstName?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + lastName?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @MaxLength(50) + @IsOptional() + phone?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + company?: string; + + @IsString() + @MaxLength(150) + @IsOptional() + jobTitle?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + website?: string; + + @IsEnum(LeadSource) + @IsOptional() + source?: LeadSource; + + @IsEnum(LeadStatus) + @IsOptional() + status?: LeadStatus; + + @IsInt() + @Min(0) + @Max(100) + @IsOptional() + score?: number; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + addressLine1?: string; + + @IsString() + @MaxLength(255) + @IsOptional() + addressLine2?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + city?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + state?: string; + + @IsString() + @MaxLength(20) + @IsOptional() + postalCode?: string; + + @IsString() + @MaxLength(100) + @IsOptional() + country?: string; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class ConvertLeadDto { + @IsString() + @MaxLength(255) + @IsOptional() + opportunityName?: string; + + @IsNumber() + @Min(0) + @IsOptional() + amount?: number; + + @IsOptional() + expectedCloseDate?: string; +} + +export class LeadResponseDto { + id: string; + tenantId: string; + firstName: string; + lastName: string; + fullName: string; + email: string | null; + phone: string | null; + company: string | null; + jobTitle: string | null; + website: string | null; + source: LeadSource; + status: LeadStatus; + score: number; + assignedTo: string | null; + notes: string | null; + convertedAt: Date | null; + convertedToOpportunityId: string | null; + addressLine1: string | null; + addressLine2: string | null; + city: string | null; + state: string | null; + postalCode: string | null; + country: string | null; + customFields: Record; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; +} + +export class LeadListQueryDto { + @IsEnum(LeadStatus) + @IsOptional() + status?: LeadStatus; + + @IsEnum(LeadSource) + @IsOptional() + source?: LeadSource; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @IsOptional() + search?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedLeadsDto { + items: LeadResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/src/modules/sales/dto/opportunity.dto.ts b/src/modules/sales/dto/opportunity.dto.ts new file mode 100644 index 0000000..b4c498c --- /dev/null +++ b/src/modules/sales/dto/opportunity.dto.ts @@ -0,0 +1,241 @@ +import { + IsString, + IsEmail, + IsOptional, + IsEnum, + IsInt, + IsUUID, + IsObject, + MaxLength, + Min, + Max, + IsNumber, + IsDateString, +} from 'class-validator'; +import { OpportunityStage } from '../entities'; + +export class CreateOpportunityDto { + @IsString() + @MaxLength(255) + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID() + @IsOptional() + leadId?: string; + + @IsEnum(OpportunityStage) + @IsOptional() + stage?: OpportunityStage; + + @IsUUID() + @IsOptional() + stageId?: string; + + @IsNumber() + @Min(0) + @IsOptional() + amount?: number; + + @IsString() + @MaxLength(3) + @IsOptional() + currency?: string; + + @IsInt() + @Min(0) + @Max(100) + @IsOptional() + probability?: number; + + @IsDateString() + @IsOptional() + expectedCloseDate?: string; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + contactName?: string; + + @IsEmail() + @IsOptional() + contactEmail?: string; + + @IsString() + @MaxLength(50) + @IsOptional() + contactPhone?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + companyName?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class UpdateOpportunityDto { + @IsString() + @MaxLength(255) + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsEnum(OpportunityStage) + @IsOptional() + stage?: OpportunityStage; + + @IsUUID() + @IsOptional() + stageId?: string; + + @IsNumber() + @Min(0) + @IsOptional() + amount?: number; + + @IsString() + @MaxLength(3) + @IsOptional() + currency?: string; + + @IsInt() + @Min(0) + @Max(100) + @IsOptional() + probability?: number; + + @IsDateString() + @IsOptional() + expectedCloseDate?: string; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @MaxLength(500) + @IsOptional() + lostReason?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + contactName?: string; + + @IsEmail() + @IsOptional() + contactEmail?: string; + + @IsString() + @MaxLength(50) + @IsOptional() + contactPhone?: string; + + @IsString() + @MaxLength(200) + @IsOptional() + companyName?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsObject() + @IsOptional() + customFields?: Record; +} + +export class UpdateOpportunityStageDto { + @IsEnum(OpportunityStage) + stage: OpportunityStage; + + @IsString() + @IsOptional() + notes?: string; +} + +export class OpportunityResponseDto { + id: string; + tenantId: string; + name: string; + description: string | null; + leadId: string | null; + stage: OpportunityStage; + stageId: string | null; + amount: number; + currency: string; + probability: number; + expectedCloseDate: Date | null; + actualCloseDate: Date | null; + assignedTo: string | null; + wonAt: Date | null; + lostAt: Date | null; + lostReason: string | null; + contactName: string | null; + contactEmail: string | null; + contactPhone: string | null; + companyName: string | null; + notes: string | null; + customFields: Record; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + lead?: any; + pipelineStage?: any; +} + +export class OpportunityListQueryDto { + @IsEnum(OpportunityStage) + @IsOptional() + stage?: OpportunityStage; + + @IsUUID() + @IsOptional() + stageId?: string; + + @IsUUID() + @IsOptional() + assignedTo?: string; + + @IsString() + @IsOptional() + search?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +export class PaginatedOpportunitiesDto { + items: OpportunityResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class PipelineSummaryDto { + stage: OpportunityStage; + count: number; + totalAmount: number; + avgProbability: number; +} diff --git a/src/modules/sales/dto/pipeline.dto.ts b/src/modules/sales/dto/pipeline.dto.ts new file mode 100644 index 0000000..a5bed30 --- /dev/null +++ b/src/modules/sales/dto/pipeline.dto.ts @@ -0,0 +1,85 @@ +import { + IsString, + IsOptional, + IsInt, + IsBoolean, + MaxLength, + Min, +} from 'class-validator'; + +export class CreatePipelineStageDto { + @IsString() + @MaxLength(100) + name: string; + + @IsInt() + @Min(0) + @IsOptional() + position?: number; + + @IsString() + @MaxLength(7) + @IsOptional() + color?: string; + + @IsBoolean() + @IsOptional() + isWon?: boolean; + + @IsBoolean() + @IsOptional() + isLost?: boolean; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class UpdatePipelineStageDto { + @IsString() + @MaxLength(100) + @IsOptional() + name?: string; + + @IsInt() + @Min(0) + @IsOptional() + position?: number; + + @IsString() + @MaxLength(7) + @IsOptional() + color?: string; + + @IsBoolean() + @IsOptional() + isWon?: boolean; + + @IsBoolean() + @IsOptional() + isLost?: boolean; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class ReorderStagesDto { + @IsString({ each: true }) + stageIds: string[]; +} + +export class PipelineStageResponseDto { + id: string; + tenantId: string; + name: string; + position: number; + color: string; + isWon: boolean; + isLost: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + opportunityCount?: number; + totalAmount?: number; +} diff --git a/src/modules/sales/entities/activity.entity.ts b/src/modules/sales/entities/activity.entity.ts new file mode 100644 index 0000000..5110793 --- /dev/null +++ b/src/modules/sales/entities/activity.entity.ts @@ -0,0 +1,123 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LeadEntity } from './lead.entity'; +import { OpportunityEntity } from './opportunity.entity'; + +export enum ActivityType { + CALL = 'call', + MEETING = 'meeting', + TASK = 'task', + EMAIL = 'email', + NOTE = 'note', +} + +export enum ActivityStatus { + PENDING = 'pending', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'sales', name: 'activities' }) +export class ActivityEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + type: 'enum', + enum: ActivityType, + enumName: 'activity_type', + }) + type: ActivityType; + + @Column({ + type: 'enum', + enum: ActivityStatus, + enumName: 'activity_status', + default: ActivityStatus.PENDING, + }) + status: ActivityStatus; + + @Column({ length: 255 }) + subject: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'lead_id', type: 'uuid', nullable: true }) + leadId: string; + + @Column({ name: 'opportunity_id', type: 'uuid', nullable: true }) + opportunityId: string; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate: Date; + + @Column({ name: 'due_time', type: 'time', nullable: true }) + dueTime: string; + + @Column({ name: 'duration_minutes', type: 'int', nullable: true }) + durationMinutes: number; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ type: 'text', nullable: true }) + outcome: string; + + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'call_direction', length: 10, nullable: true }) + callDirection: string; + + @Column({ name: 'call_recording_url', length: 500, nullable: true }) + callRecordingUrl: string; + + @Column({ length: 255, nullable: true }) + location: string; + + @Column({ name: 'meeting_url', length: 500, nullable: true }) + meetingUrl: string; + + @Column({ type: 'jsonb', default: [] }) + attendees: any[]; + + @Column({ name: 'reminder_at', type: 'timestamptz', nullable: true }) + reminderAt: Date; + + @Column({ name: 'reminder_sent', default: false }) + reminderSent: boolean; + + @Column({ name: 'custom_fields', type: 'jsonb', default: {} }) + customFields: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @ManyToOne(() => LeadEntity, (lead) => lead.activities) + @JoinColumn({ name: 'lead_id' }) + lead: LeadEntity; + + @ManyToOne(() => OpportunityEntity, (opportunity) => opportunity.activities) + @JoinColumn({ name: 'opportunity_id' }) + opportunity: OpportunityEntity; +} diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 0000000..98158b5 --- /dev/null +++ b/src/modules/sales/entities/index.ts @@ -0,0 +1,4 @@ +export * from './pipeline-stage.entity'; +export * from './lead.entity'; +export * from './opportunity.entity'; +export * from './activity.entity'; diff --git a/src/modules/sales/entities/lead.entity.ts b/src/modules/sales/entities/lead.entity.ts new file mode 100644 index 0000000..066c8ff --- /dev/null +++ b/src/modules/sales/entities/lead.entity.ts @@ -0,0 +1,128 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; + +export enum LeadStatus { + NEW = 'new', + CONTACTED = 'contacted', + QUALIFIED = 'qualified', + UNQUALIFIED = 'unqualified', + CONVERTED = 'converted', +} + +export enum LeadSource { + WEBSITE = 'website', + REFERRAL = 'referral', + COLD_CALL = 'cold_call', + EVENT = 'event', + ADVERTISEMENT = 'advertisement', + SOCIAL_MEDIA = 'social_media', + OTHER = 'other', +} + +@Entity({ schema: 'sales', name: 'leads' }) +export class LeadEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'first_name', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', length: 100 }) + lastName: string; + + @Column({ length: 255, nullable: true }) + email: string; + + @Column({ length: 50, nullable: true }) + phone: string; + + @Column({ length: 200, nullable: true }) + company: string; + + @Column({ name: 'job_title', length: 150, nullable: true }) + jobTitle: string; + + @Column({ length: 255, nullable: true }) + website: string; + + @Column({ + type: 'enum', + enum: LeadSource, + enumName: 'lead_source', + default: LeadSource.OTHER, + }) + source: LeadSource; + + @Column({ + type: 'enum', + enum: LeadStatus, + enumName: 'lead_status', + default: LeadStatus.NEW, + }) + status: LeadStatus; + + @Column({ type: 'int', default: 0 }) + score: number; + + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt: Date; + + @Column({ name: 'converted_to_opportunity_id', type: 'uuid', nullable: true }) + convertedToOpportunityId: string; + + @Column({ name: 'address_line1', length: 255, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', length: 255, nullable: true }) + addressLine2: string; + + @Column({ length: 100, nullable: true }) + city: string; + + @Column({ length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', length: 20, nullable: true }) + postalCode: string; + + @Column({ length: 100, nullable: true }) + country: string; + + @Column({ name: 'custom_fields', type: 'jsonb', default: {} }) + customFields: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @OneToMany('ActivityEntity', 'lead') + activities: any[]; + + @OneToMany('OpportunityEntity', 'lead') + opportunities: any[]; +} diff --git a/src/modules/sales/entities/opportunity.entity.ts b/src/modules/sales/entities/opportunity.entity.ts new file mode 100644 index 0000000..f8c1de8 --- /dev/null +++ b/src/modules/sales/entities/opportunity.entity.ts @@ -0,0 +1,118 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { LeadEntity } from './lead.entity'; +import { PipelineStageEntity } from './pipeline-stage.entity'; + +export enum OpportunityStage { + PROSPECTING = 'prospecting', + QUALIFICATION = 'qualification', + PROPOSAL = 'proposal', + NEGOTIATION = 'negotiation', + CLOSED_WON = 'closed_won', + CLOSED_LOST = 'closed_lost', +} + +@Entity({ schema: 'sales', name: 'opportunities' }) +export class OpportunityEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'lead_id', type: 'uuid', nullable: true }) + leadId: string; + + @Column({ + type: 'enum', + enum: OpportunityStage, + enumName: 'opportunity_stage', + default: OpportunityStage.PROSPECTING, + }) + stage: OpportunityStage; + + @Column({ name: 'stage_id', type: 'uuid', nullable: true }) + stageId: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + amount: number; + + @Column({ length: 3, default: 'USD' }) + currency: string; + + @Column({ type: 'int', default: 0 }) + probability: number; + + @Column({ name: 'expected_close_date', type: 'date', nullable: true }) + expectedCloseDate: Date; + + @Column({ name: 'actual_close_date', type: 'date', nullable: true }) + actualCloseDate: Date; + + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'won_at', type: 'timestamptz', nullable: true }) + wonAt: Date; + + @Column({ name: 'lost_at', type: 'timestamptz', nullable: true }) + lostAt: Date; + + @Column({ name: 'lost_reason', length: 500, nullable: true }) + lostReason: string; + + @Column({ name: 'contact_name', length: 200, nullable: true }) + contactName: string; + + @Column({ name: 'contact_email', length: 255, nullable: true }) + contactEmail: string; + + @Column({ name: 'contact_phone', length: 50, nullable: true }) + contactPhone: string; + + @Column({ name: 'company_name', length: 200, nullable: true }) + companyName: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'custom_fields', type: 'jsonb', default: {} }) + customFields: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @ManyToOne(() => LeadEntity, (lead) => lead.opportunities) + @JoinColumn({ name: 'lead_id' }) + lead: LeadEntity; + + @ManyToOne(() => PipelineStageEntity, (stage) => stage.opportunities) + @JoinColumn({ name: 'stage_id' }) + pipelineStage: PipelineStageEntity; + + @OneToMany('ActivityEntity', 'opportunity') + activities: any[]; +} diff --git a/src/modules/sales/entities/pipeline-stage.entity.ts b/src/modules/sales/entities/pipeline-stage.entity.ts new file mode 100644 index 0000000..6c36c50 --- /dev/null +++ b/src/modules/sales/entities/pipeline-stage.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; + +@Entity({ schema: 'sales', name: 'pipeline_stages' }) +export class PipelineStageEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'int', default: 0 }) + position: number; + + @Column({ length: 7, default: '#3B82F6' }) + color: string; + + @Column({ name: 'is_won', default: false }) + isWon: boolean; + + @Column({ name: 'is_lost', default: false }) + isLost: boolean; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @OneToMany('OpportunityEntity', 'pipelineStage') + opportunities: any[]; +} diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts new file mode 100644 index 0000000..2783fce --- /dev/null +++ b/src/modules/sales/index.ts @@ -0,0 +1,5 @@ +export * from './sales.module'; +export * from './entities'; +export * from './dto'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/sales/sales.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..8ee5ef4 --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -0,0 +1,58 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { + PipelineStageEntity, + LeadEntity, + OpportunityEntity, + ActivityEntity, +} from './entities'; + +import { + LeadsService, + OpportunitiesService, + ActivitiesService, + PipelineService, + SalesDashboardService, +} from './services'; + +import { + LeadsController, + OpportunitiesController, + ActivitiesController, + PipelineController, + SalesDashboardController, +} from './controllers'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + PipelineStageEntity, + LeadEntity, + OpportunityEntity, + ActivityEntity, + ]), + ], + controllers: [ + LeadsController, + OpportunitiesController, + ActivitiesController, + PipelineController, + SalesDashboardController, + ], + providers: [ + LeadsService, + OpportunitiesService, + ActivitiesService, + PipelineService, + SalesDashboardService, + ], + exports: [ + LeadsService, + OpportunitiesService, + ActivitiesService, + PipelineService, + SalesDashboardService, + ], +}) +export class SalesModule {} diff --git a/src/modules/sales/services/activities.service.ts b/src/modules/sales/services/activities.service.ts new file mode 100644 index 0000000..096f9d5 --- /dev/null +++ b/src/modules/sales/services/activities.service.ts @@ -0,0 +1,243 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { ActivityEntity, ActivityStatus } from '../entities'; +import { + CreateActivityDto, + UpdateActivityDto, + CompleteActivityDto, + ActivityResponseDto, + ActivityListQueryDto, + PaginatedActivitiesDto, +} from '../dto'; + +@Injectable() +export class ActivitiesService { + private readonly logger = new Logger(ActivitiesService.name); + + constructor( + @InjectRepository(ActivityEntity) + private readonly activityRepo: Repository, + ) {} + + async create(tenantId: string, userId: string, dto: CreateActivityDto): Promise { + if (!dto.leadId && !dto.opportunityId) { + throw new BadRequestException('Either leadId or opportunityId must be provided'); + } + + const activity = this.activityRepo.create({ + tenantId, + type: dto.type, + subject: dto.subject, + description: dto.description, + leadId: dto.leadId, + opportunityId: dto.opportunityId, + dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + dueTime: dto.dueTime, + durationMinutes: dto.durationMinutes, + assignedTo: dto.assignedTo, + callDirection: dto.callDirection, + callRecordingUrl: dto.callRecordingUrl, + location: dto.location, + meetingUrl: dto.meetingUrl, + attendees: dto.attendees ?? [], + reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : undefined, + customFields: dto.customFields ?? {}, + createdBy: userId, + }); + + const saved = await this.activityRepo.save(activity); + this.logger.log(`Activity created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: ActivityListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.activityRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.lead', 'lead') + .leftJoinAndSelect('a.opportunity', 'opportunity') + .where('a.tenant_id = :tenantId', { tenantId }) + .andWhere('a.deleted_at IS NULL'); + + if (query.type) { + qb.andWhere('a.type = :type', { type: query.type }); + } + + if (query.status) { + qb.andWhere('a.status = :status', { status: query.status }); + } + + if (query.leadId) { + qb.andWhere('a.lead_id = :leadId', { leadId: query.leadId }); + } + + if (query.opportunityId) { + qb.andWhere('a.opportunity_id = :opportunityId', { opportunityId: query.opportunityId }); + } + + if (query.assignedTo) { + qb.andWhere('a.assigned_to = :assignedTo', { assignedTo: query.assignedTo }); + } + + qb.orderBy('a.due_date', 'ASC').addOrderBy('a.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((a) => this.toResponse(a)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, activityId: string): Promise { + const activity = await this.activityRepo.findOne({ + where: { id: activityId, tenantId, deletedAt: null as any }, + relations: ['lead', 'opportunity'], + }); + + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + return this.toResponse(activity); + } + + async update(tenantId: string, activityId: string, dto: UpdateActivityDto): Promise { + const activity = await this.activityRepo.findOne({ + where: { id: activityId, tenantId, deletedAt: null as any }, + }); + + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + Object.assign(activity, { + type: dto.type ?? activity.type, + status: dto.status ?? activity.status, + subject: dto.subject ?? activity.subject, + description: dto.description ?? activity.description, + dueDate: dto.dueDate ? new Date(dto.dueDate) : activity.dueDate, + dueTime: dto.dueTime ?? activity.dueTime, + durationMinutes: dto.durationMinutes ?? activity.durationMinutes, + outcome: dto.outcome ?? activity.outcome, + assignedTo: dto.assignedTo ?? activity.assignedTo, + callDirection: dto.callDirection ?? activity.callDirection, + callRecordingUrl: dto.callRecordingUrl ?? activity.callRecordingUrl, + location: dto.location ?? activity.location, + meetingUrl: dto.meetingUrl ?? activity.meetingUrl, + attendees: dto.attendees ?? activity.attendees, + reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : activity.reminderAt, + reminderSent: dto.reminderSent ?? activity.reminderSent, + customFields: dto.customFields ?? activity.customFields, + }); + + const saved = await this.activityRepo.save(activity); + this.logger.log(`Activity updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async complete( + tenantId: string, + activityId: string, + dto: CompleteActivityDto, + ): Promise { + const activity = await this.activityRepo.findOne({ + where: { id: activityId, tenantId, deletedAt: null as any }, + }); + + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + if (activity.status === ActivityStatus.COMPLETED) { + throw new BadRequestException('Activity is already completed'); + } + + activity.status = ActivityStatus.COMPLETED; + activity.completedAt = new Date(); + activity.outcome = dto.outcome ?? activity.outcome; + + const saved = await this.activityRepo.save(activity); + this.logger.log(`Activity completed: ${saved.id}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, activityId: string): Promise { + const activity = await this.activityRepo.findOne({ + where: { id: activityId, tenantId, deletedAt: null as any }, + }); + + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + activity.deletedAt = new Date(); + await this.activityRepo.save(activity); + this.logger.log(`Activity soft-deleted: ${activityId}`); + } + + async getUpcoming(tenantId: string, userId?: string, days: number = 7): Promise { + const qb = this.activityRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.lead', 'lead') + .leftJoinAndSelect('a.opportunity', 'opportunity') + .where('a.tenant_id = :tenantId', { tenantId }) + .andWhere('a.deleted_at IS NULL') + .andWhere('a.status = :status', { status: ActivityStatus.PENDING }) + .andWhere('a.due_date <= :endDate', { + endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000), + }); + + if (userId) { + qb.andWhere('a.assigned_to = :userId', { userId }); + } + + qb.orderBy('a.due_date', 'ASC').take(50); + + const activities = await qb.getMany(); + return activities.map((a) => this.toResponse(a)); + } + + private toResponse(activity: ActivityEntity): ActivityResponseDto { + return { + id: activity.id, + tenantId: activity.tenantId, + type: activity.type, + status: activity.status, + subject: activity.subject, + description: activity.description, + leadId: activity.leadId, + opportunityId: activity.opportunityId, + dueDate: activity.dueDate, + dueTime: activity.dueTime, + durationMinutes: activity.durationMinutes, + completedAt: activity.completedAt, + outcome: activity.outcome, + assignedTo: activity.assignedTo, + createdBy: activity.createdBy, + callDirection: activity.callDirection, + callRecordingUrl: activity.callRecordingUrl, + location: activity.location, + meetingUrl: activity.meetingUrl, + attendees: activity.attendees, + reminderAt: activity.reminderAt, + reminderSent: activity.reminderSent, + customFields: activity.customFields, + createdAt: activity.createdAt, + updatedAt: activity.updatedAt, + lead: activity.lead, + opportunity: activity.opportunity, + }; + } +} diff --git a/src/modules/sales/services/index.ts b/src/modules/sales/services/index.ts new file mode 100644 index 0000000..3dd5663 --- /dev/null +++ b/src/modules/sales/services/index.ts @@ -0,0 +1,5 @@ +export * from './leads.service'; +export * from './opportunities.service'; +export * from './activities.service'; +export * from './pipeline.service'; +export * from './sales-dashboard.service'; diff --git a/src/modules/sales/services/leads.service.ts b/src/modules/sales/services/leads.service.ts new file mode 100644 index 0000000..bd5f41e --- /dev/null +++ b/src/modules/sales/services/leads.service.ts @@ -0,0 +1,265 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { LeadEntity, LeadStatus } from '../entities'; +import { + CreateLeadDto, + UpdateLeadDto, + ConvertLeadDto, + LeadResponseDto, + LeadListQueryDto, + PaginatedLeadsDto, +} from '../dto'; + +@Injectable() +export class LeadsService { + private readonly logger = new Logger(LeadsService.name); + + constructor( + @InjectRepository(LeadEntity) + private readonly leadRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(tenantId: string, userId: string, dto: CreateLeadDto): Promise { + const lead = this.leadRepo.create({ + tenantId, + firstName: dto.firstName, + lastName: dto.lastName, + email: dto.email, + phone: dto.phone, + company: dto.company, + jobTitle: dto.jobTitle, + website: dto.website, + source: dto.source, + status: dto.status, + score: dto.score ?? 0, + assignedTo: dto.assignedTo, + notes: dto.notes, + addressLine1: dto.addressLine1, + addressLine2: dto.addressLine2, + city: dto.city, + state: dto.state, + postalCode: dto.postalCode, + country: dto.country, + customFields: dto.customFields ?? {}, + createdBy: userId, + }); + + const saved = await this.leadRepo.save(lead); + this.logger.log(`Lead created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: LeadListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.leadRepo + .createQueryBuilder('l') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.deleted_at IS NULL'); + + if (query.status) { + qb.andWhere('l.status = :status', { status: query.status }); + } + + if (query.source) { + qb.andWhere('l.source = :source', { source: query.source }); + } + + if (query.assignedTo) { + qb.andWhere('l.assigned_to = :assignedTo', { assignedTo: query.assignedTo }); + } + + if (query.search) { + qb.andWhere( + '(l.first_name ILIKE :search OR l.last_name ILIKE :search OR l.email ILIKE :search OR l.company ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + qb.orderBy('l.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((l) => this.toResponse(l)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, leadId: string): Promise { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + return this.toResponse(lead); + } + + async update(tenantId: string, leadId: string, dto: UpdateLeadDto): Promise { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + Object.assign(lead, { + firstName: dto.firstName ?? lead.firstName, + lastName: dto.lastName ?? lead.lastName, + email: dto.email ?? lead.email, + phone: dto.phone ?? lead.phone, + company: dto.company ?? lead.company, + jobTitle: dto.jobTitle ?? lead.jobTitle, + website: dto.website ?? lead.website, + source: dto.source ?? lead.source, + status: dto.status ?? lead.status, + score: dto.score ?? lead.score, + assignedTo: dto.assignedTo ?? lead.assignedTo, + notes: dto.notes ?? lead.notes, + addressLine1: dto.addressLine1 ?? lead.addressLine1, + addressLine2: dto.addressLine2 ?? lead.addressLine2, + city: dto.city ?? lead.city, + state: dto.state ?? lead.state, + postalCode: dto.postalCode ?? lead.postalCode, + country: dto.country ?? lead.country, + customFields: dto.customFields ?? lead.customFields, + }); + + const saved = await this.leadRepo.save(lead); + this.logger.log(`Lead updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, leadId: string): Promise { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + lead.deletedAt = new Date(); + await this.leadRepo.save(lead); + this.logger.log(`Lead soft-deleted: ${leadId}`); + } + + async convert( + tenantId: string, + leadId: string, + dto: ConvertLeadDto, + ): Promise<{ opportunityId: string }> { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + if (lead.status === LeadStatus.CONVERTED) { + throw new BadRequestException('Lead is already converted'); + } + + // Call the database function to convert lead to opportunity + const result = await this.dataSource.query( + `SELECT sales.convert_lead_to_opportunity($1, $2, $3, $4) as opportunity_id`, + [ + leadId, + dto.opportunityName || null, + dto.amount || 0, + dto.expectedCloseDate || null, + ], + ); + + const opportunityId = result[0]?.opportunity_id; + this.logger.log(`Lead ${leadId} converted to opportunity ${opportunityId}`); + + return { opportunityId }; + } + + async calculateScore(tenantId: string, leadId: string): Promise<{ score: number }> { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + const result = await this.dataSource.query( + `SELECT sales.calculate_lead_score($1) as score`, + [leadId], + ); + + const score = result[0]?.score ?? 0; + + // Update lead score + lead.score = score; + await this.leadRepo.save(lead); + + return { score }; + } + + async assignTo(tenantId: string, leadId: string, userId: string): Promise { + const lead = await this.leadRepo.findOne({ + where: { id: leadId, tenantId, deletedAt: null as any }, + }); + + if (!lead) { + throw new NotFoundException('Lead not found'); + } + + lead.assignedTo = userId; + const saved = await this.leadRepo.save(lead); + + this.logger.log(`Lead ${leadId} assigned to user ${userId}`); + return this.toResponse(saved); + } + + private toResponse(lead: LeadEntity): LeadResponseDto { + return { + id: lead.id, + tenantId: lead.tenantId, + firstName: lead.firstName, + lastName: lead.lastName, + fullName: `${lead.firstName} ${lead.lastName}`, + email: lead.email, + phone: lead.phone, + company: lead.company, + jobTitle: lead.jobTitle, + website: lead.website, + source: lead.source, + status: lead.status, + score: lead.score, + assignedTo: lead.assignedTo, + notes: lead.notes, + convertedAt: lead.convertedAt, + convertedToOpportunityId: lead.convertedToOpportunityId, + addressLine1: lead.addressLine1, + addressLine2: lead.addressLine2, + city: lead.city, + state: lead.state, + postalCode: lead.postalCode, + country: lead.country, + customFields: lead.customFields, + createdAt: lead.createdAt, + updatedAt: lead.updatedAt, + createdBy: lead.createdBy, + }; + } +} diff --git a/src/modules/sales/services/opportunities.service.ts b/src/modules/sales/services/opportunities.service.ts new file mode 100644 index 0000000..be0c260 --- /dev/null +++ b/src/modules/sales/services/opportunities.service.ts @@ -0,0 +1,255 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { OpportunityEntity, OpportunityStage } from '../entities'; +import { + CreateOpportunityDto, + UpdateOpportunityDto, + UpdateOpportunityStageDto, + OpportunityResponseDto, + OpportunityListQueryDto, + PaginatedOpportunitiesDto, + PipelineSummaryDto, +} from '../dto'; + +@Injectable() +export class OpportunitiesService { + private readonly logger = new Logger(OpportunitiesService.name); + + constructor( + @InjectRepository(OpportunityEntity) + private readonly opportunityRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(tenantId: string, userId: string, dto: CreateOpportunityDto): Promise { + const opportunity = this.opportunityRepo.create({ + tenantId, + name: dto.name, + description: dto.description, + leadId: dto.leadId, + stage: dto.stage ?? OpportunityStage.PROSPECTING, + stageId: dto.stageId, + amount: dto.amount ?? 0, + currency: dto.currency ?? 'USD', + probability: dto.probability ?? 0, + expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined, + assignedTo: dto.assignedTo, + contactName: dto.contactName, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + companyName: dto.companyName, + notes: dto.notes, + customFields: dto.customFields ?? {}, + createdBy: userId, + }); + + const saved = await this.opportunityRepo.save(opportunity); + this.logger.log(`Opportunity created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async findAll(tenantId: string, query: OpportunityListQueryDto): Promise { + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.opportunityRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.lead', 'lead') + .leftJoinAndSelect('o.pipelineStage', 'pipelineStage') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.deleted_at IS NULL'); + + if (query.stage) { + qb.andWhere('o.stage = :stage', { stage: query.stage }); + } + + if (query.stageId) { + qb.andWhere('o.stage_id = :stageId', { stageId: query.stageId }); + } + + if (query.assignedTo) { + qb.andWhere('o.assigned_to = :assignedTo', { assignedTo: query.assignedTo }); + } + + if (query.search) { + qb.andWhere( + '(o.name ILIKE :search OR o.company_name ILIKE :search OR o.contact_name ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((o) => this.toResponse(o)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, opportunityId: string): Promise { + const opportunity = await this.opportunityRepo.findOne({ + where: { id: opportunityId, tenantId, deletedAt: null as any }, + relations: ['lead', 'pipelineStage'], + }); + + if (!opportunity) { + throw new NotFoundException('Opportunity not found'); + } + + return this.toResponse(opportunity); + } + + async update( + tenantId: string, + opportunityId: string, + dto: UpdateOpportunityDto, + ): Promise { + const opportunity = await this.opportunityRepo.findOne({ + where: { id: opportunityId, tenantId, deletedAt: null as any }, + }); + + if (!opportunity) { + throw new NotFoundException('Opportunity not found'); + } + + Object.assign(opportunity, { + name: dto.name ?? opportunity.name, + description: dto.description ?? opportunity.description, + stage: dto.stage ?? opportunity.stage, + stageId: dto.stageId ?? opportunity.stageId, + amount: dto.amount ?? opportunity.amount, + currency: dto.currency ?? opportunity.currency, + probability: dto.probability ?? opportunity.probability, + expectedCloseDate: dto.expectedCloseDate + ? new Date(dto.expectedCloseDate) + : opportunity.expectedCloseDate, + assignedTo: dto.assignedTo ?? opportunity.assignedTo, + lostReason: dto.lostReason ?? opportunity.lostReason, + contactName: dto.contactName ?? opportunity.contactName, + contactEmail: dto.contactEmail ?? opportunity.contactEmail, + contactPhone: dto.contactPhone ?? opportunity.contactPhone, + companyName: dto.companyName ?? opportunity.companyName, + notes: dto.notes ?? opportunity.notes, + customFields: dto.customFields ?? opportunity.customFields, + }); + + const saved = await this.opportunityRepo.save(opportunity); + this.logger.log(`Opportunity updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async updateStage( + tenantId: string, + opportunityId: string, + dto: UpdateOpportunityStageDto, + ): Promise { + const opportunity = await this.opportunityRepo.findOne({ + where: { id: opportunityId, tenantId, deletedAt: null as any }, + }); + + if (!opportunity) { + throw new NotFoundException('Opportunity not found'); + } + + // Use database function for stage update with tracking + await this.dataSource.query( + `SELECT sales.update_opportunity_stage($1, $2, $3)`, + [opportunityId, dto.stage, dto.notes || null], + ); + + // Reload the opportunity + const updated = await this.opportunityRepo.findOne({ + where: { id: opportunityId }, + relations: ['lead', 'pipelineStage'], + }); + + this.logger.log(`Opportunity ${opportunityId} stage updated to ${dto.stage}`); + return this.toResponse(updated!); + } + + async remove(tenantId: string, opportunityId: string): Promise { + const opportunity = await this.opportunityRepo.findOne({ + where: { id: opportunityId, tenantId, deletedAt: null as any }, + }); + + if (!opportunity) { + throw new NotFoundException('Opportunity not found'); + } + + opportunity.deletedAt = new Date(); + await this.opportunityRepo.save(opportunity); + this.logger.log(`Opportunity soft-deleted: ${opportunityId}`); + } + + async getPipelineSummary(tenantId: string): Promise { + const result = await this.dataSource.query( + `SELECT * FROM sales.get_pipeline_summary($1)`, + [tenantId], + ); + + return result.map((row: any) => ({ + stage: row.stage, + count: Number(row.count), + totalAmount: Number(row.total_amount), + avgProbability: Number(row.avg_probability), + })); + } + + async assignTo(tenantId: string, opportunityId: string, userId: string): Promise { + const opportunity = await this.opportunityRepo.findOne({ + where: { id: opportunityId, tenantId, deletedAt: null as any }, + }); + + if (!opportunity) { + throw new NotFoundException('Opportunity not found'); + } + + opportunity.assignedTo = userId; + const saved = await this.opportunityRepo.save(opportunity); + + this.logger.log(`Opportunity ${opportunityId} assigned to user ${userId}`); + return this.toResponse(saved); + } + + private toResponse(opportunity: OpportunityEntity): OpportunityResponseDto { + return { + id: opportunity.id, + tenantId: opportunity.tenantId, + name: opportunity.name, + description: opportunity.description, + leadId: opportunity.leadId, + stage: opportunity.stage, + stageId: opportunity.stageId, + amount: Number(opportunity.amount), + currency: opportunity.currency, + probability: opportunity.probability, + expectedCloseDate: opportunity.expectedCloseDate, + actualCloseDate: opportunity.actualCloseDate, + assignedTo: opportunity.assignedTo, + wonAt: opportunity.wonAt, + lostAt: opportunity.lostAt, + lostReason: opportunity.lostReason, + contactName: opportunity.contactName, + contactEmail: opportunity.contactEmail, + contactPhone: opportunity.contactPhone, + companyName: opportunity.companyName, + notes: opportunity.notes, + customFields: opportunity.customFields, + createdAt: opportunity.createdAt, + updatedAt: opportunity.updatedAt, + createdBy: opportunity.createdBy, + lead: opportunity.lead, + pipelineStage: opportunity.pipelineStage, + }; + } +} diff --git a/src/modules/sales/services/pipeline.service.ts b/src/modules/sales/services/pipeline.service.ts new file mode 100644 index 0000000..4f0b62c --- /dev/null +++ b/src/modules/sales/services/pipeline.service.ts @@ -0,0 +1,186 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { PipelineStageEntity, OpportunityEntity } from '../entities'; +import { + CreatePipelineStageDto, + UpdatePipelineStageDto, + ReorderStagesDto, + PipelineStageResponseDto, +} from '../dto'; + +@Injectable() +export class PipelineService { + private readonly logger = new Logger(PipelineService.name); + + constructor( + @InjectRepository(PipelineStageEntity) + private readonly stageRepo: Repository, + @InjectRepository(OpportunityEntity) + private readonly opportunityRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async initializeDefaults(tenantId: string): Promise { + await this.dataSource.query(`SELECT sales.initialize_default_stages($1)`, [tenantId]); + this.logger.log(`Default pipeline stages initialized for tenant ${tenantId}`); + } + + async findAll(tenantId: string): Promise { + const stages = await this.stageRepo.find({ + where: { tenantId }, + order: { position: 'ASC' }, + }); + + // Get opportunity counts and totals for each stage + const stats = await this.opportunityRepo + .createQueryBuilder('o') + .select('o.stage_id', 'stageId') + .addSelect('COUNT(*)::int', 'count') + .addSelect('COALESCE(SUM(o.amount), 0)', 'total') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.deleted_at IS NULL') + .groupBy('o.stage_id') + .getRawMany(); + + const statsMap = new Map(stats.map((s) => [s.stageId, s])); + + return stages.map((stage) => { + const stageStats = statsMap.get(stage.id) || { count: 0, total: 0 }; + return { + ...this.toResponse(stage), + opportunityCount: Number(stageStats.count), + totalAmount: Number(stageStats.total), + }; + }); + } + + async findOne(tenantId: string, stageId: string): Promise { + const stage = await this.stageRepo.findOne({ + where: { id: stageId, tenantId }, + }); + + if (!stage) { + throw new NotFoundException('Pipeline stage not found'); + } + + return this.toResponse(stage); + } + + async create(tenantId: string, dto: CreatePipelineStageDto): Promise { + // Get max position + const maxPosition = await this.stageRepo + .createQueryBuilder('s') + .select('MAX(s.position)', 'max') + .where('s.tenant_id = :tenantId', { tenantId }) + .getRawOne(); + + const position = dto.position ?? (maxPosition?.max ?? 0) + 1; + + const stage = this.stageRepo.create({ + tenantId, + name: dto.name, + position, + color: dto.color ?? '#3B82F6', + isWon: dto.isWon ?? false, + isLost: dto.isLost ?? false, + isActive: dto.isActive ?? true, + }); + + const saved = await this.stageRepo.save(stage); + this.logger.log(`Pipeline stage created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved); + } + + async update( + tenantId: string, + stageId: string, + dto: UpdatePipelineStageDto, + ): Promise { + const stage = await this.stageRepo.findOne({ + where: { id: stageId, tenantId }, + }); + + if (!stage) { + throw new NotFoundException('Pipeline stage not found'); + } + + Object.assign(stage, { + name: dto.name ?? stage.name, + position: dto.position ?? stage.position, + color: dto.color ?? stage.color, + isWon: dto.isWon ?? stage.isWon, + isLost: dto.isLost ?? stage.isLost, + isActive: dto.isActive ?? stage.isActive, + }); + + const saved = await this.stageRepo.save(stage); + this.logger.log(`Pipeline stage updated: ${saved.id}`); + + return this.toResponse(saved); + } + + async remove(tenantId: string, stageId: string): Promise { + const stage = await this.stageRepo.findOne({ + where: { id: stageId, tenantId }, + }); + + if (!stage) { + throw new NotFoundException('Pipeline stage not found'); + } + + // Check if there are opportunities in this stage + const opportunityCount = await this.opportunityRepo.count({ + where: { stageId, tenantId, deletedAt: null as any }, + }); + + if (opportunityCount > 0) { + throw new NotFoundException( + `Cannot delete stage with ${opportunityCount} opportunities. Move them first.`, + ); + } + + await this.stageRepo.remove(stage); + this.logger.log(`Pipeline stage deleted: ${stageId}`); + } + + async reorder(tenantId: string, dto: ReorderStagesDto): Promise { + const stages = await this.stageRepo.find({ + where: { tenantId }, + }); + + const stageMap = new Map(stages.map((s) => [s.id, s])); + + // Update positions based on the order in dto.stageIds + const updates = dto.stageIds.map((id, index) => { + const stage = stageMap.get(id); + if (stage) { + stage.position = index + 1; + return stage; + } + return null; + }).filter(Boolean); + + await this.stageRepo.save(updates as PipelineStageEntity[]); + this.logger.log(`Pipeline stages reordered for tenant ${tenantId}`); + + return this.findAll(tenantId); + } + + private toResponse(stage: PipelineStageEntity): PipelineStageResponseDto { + return { + id: stage.id, + tenantId: stage.tenantId, + name: stage.name, + position: stage.position, + color: stage.color, + isWon: stage.isWon, + isLost: stage.isLost, + isActive: stage.isActive, + createdAt: stage.createdAt, + updatedAt: stage.updatedAt, + }; + } +} diff --git a/src/modules/sales/services/sales-dashboard.service.ts b/src/modules/sales/services/sales-dashboard.service.ts new file mode 100644 index 0000000..50b53b3 --- /dev/null +++ b/src/modules/sales/services/sales-dashboard.service.ts @@ -0,0 +1,227 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { LeadEntity, OpportunityEntity, ActivityEntity, OpportunityStage, LeadStatus, ActivityStatus } from '../entities'; +import { + SalesDashboardDto, + LeadsByStatusDto, + LeadsBySourceDto, + OpportunitiesByStageDto, + ConversionFunnelDto, + SalesPerformanceDto, +} from '../dto'; + +@Injectable() +export class SalesDashboardService { + private readonly logger = new Logger(SalesDashboardService.name); + + constructor( + @InjectRepository(LeadEntity) + private readonly leadRepo: Repository, + @InjectRepository(OpportunityEntity) + private readonly opportunityRepo: Repository, + @InjectRepository(ActivityEntity) + private readonly activityRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async getDashboardSummary(tenantId: string): Promise { + // Total leads + const totalLeads = await this.leadRepo.count({ + where: { tenantId, deletedAt: null as any }, + }); + + // Total opportunities + const totalOpportunities = await this.opportunityRepo.count({ + where: { tenantId, deletedAt: null as any }, + }); + + // Pipeline value and won deals + const pipelineStats = await this.opportunityRepo + .createQueryBuilder('o') + .select('COUNT(*)', 'total') + .addSelect('COALESCE(SUM(o.amount), 0)', 'totalValue') + .addSelect('COUNT(*) FILTER (WHERE o.stage = :wonStage)', 'wonCount') + .addSelect('COALESCE(SUM(o.amount) FILTER (WHERE o.stage = :wonStage), 0)', 'wonValue') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.deleted_at IS NULL') + .setParameter('wonStage', OpportunityStage.CLOSED_WON) + .getRawOne(); + + // Conversion rate (leads converted to opportunities) + const convertedLeads = await this.leadRepo.count({ + where: { tenantId, status: LeadStatus.CONVERTED, deletedAt: null as any }, + }); + const conversionRate = totalLeads > 0 ? (convertedLeads / totalLeads) * 100 : 0; + + // Average deal size + const avgDealSize = + Number(pipelineStats.wonCount) > 0 + ? Number(pipelineStats.wonValue) / Number(pipelineStats.wonCount) + : 0; + + // Activities this week + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + + const activitiesThisWeek = await this.activityRepo.count({ + where: { + tenantId, + status: ActivityStatus.COMPLETED, + deletedAt: null as any, + }, + }); + + return { + totalLeads, + totalOpportunities, + totalPipelineValue: Number(pipelineStats.totalValue), + wonDeals: Number(pipelineStats.wonCount), + conversionRate: Math.round(conversionRate * 100) / 100, + averageDealSize: Math.round(avgDealSize * 100) / 100, + activitiesThisWeek, + currency: 'USD', + }; + } + + async getLeadsByStatus(tenantId: string): Promise { + const result = await this.leadRepo + .createQueryBuilder('l') + .select('l.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.deleted_at IS NULL') + .groupBy('l.status') + .getRawMany(); + + const total = result.reduce((sum, r) => sum + r.count, 0); + + return result.map((r) => ({ + status: r.status, + count: r.count, + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getLeadsBySource(tenantId: string): Promise { + const result = await this.leadRepo + .createQueryBuilder('l') + .select('l.source', 'source') + .addSelect('COUNT(*)::int', 'count') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.deleted_at IS NULL') + .groupBy('l.source') + .getRawMany(); + + const total = result.reduce((sum, r) => sum + r.count, 0); + + return result.map((r) => ({ + source: r.source, + count: r.count, + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getOpportunitiesByStage(tenantId: string): Promise { + const result = await this.opportunityRepo + .createQueryBuilder('o') + .select('o.stage', 'stage') + .addSelect('COUNT(*)::int', 'count') + .addSelect('COALESCE(SUM(o.amount), 0)', 'totalAmount') + .where('o.tenant_id = :tenantId', { tenantId }) + .andWhere('o.deleted_at IS NULL') + .groupBy('o.stage') + .getRawMany(); + + const total = result.reduce((sum, r) => sum + r.count, 0); + + return result.map((r) => ({ + stage: r.stage, + count: r.count, + totalAmount: Number(r.totalAmount), + percentage: total > 0 ? Math.round((r.count / total) * 100) : 0, + })); + } + + async getConversionFunnel(tenantId: string): Promise { + // Define funnel stages + const stages = [ + { stage: 'New Leads', query: { status: LeadStatus.NEW } }, + { stage: 'Contacted', query: { status: LeadStatus.CONTACTED } }, + { stage: 'Qualified', query: { status: LeadStatus.QUALIFIED } }, + { stage: 'Converted', query: { status: LeadStatus.CONVERTED } }, + ]; + + const totalLeads = await this.leadRepo.count({ + where: { tenantId, deletedAt: null as any }, + }); + + let previousCount = totalLeads; + const funnel: ConversionFunnelDto[] = []; + + for (const { stage, query } of stages) { + const count = await this.leadRepo.count({ + where: { tenantId, ...query, deletedAt: null as any }, + }); + + funnel.push({ + stage, + count, + percentage: totalLeads > 0 ? Math.round((count / totalLeads) * 100) : 0, + conversionFromPrevious: + previousCount > 0 ? Math.round((count / previousCount) * 100) : 0, + }); + + previousCount = count > 0 ? count : previousCount; + } + + // Add opportunity stages + const wonOpportunities = await this.opportunityRepo.count({ + where: { tenantId, stage: OpportunityStage.CLOSED_WON, deletedAt: null as any }, + }); + + funnel.push({ + stage: 'Won Deals', + count: wonOpportunities, + percentage: totalLeads > 0 ? Math.round((wonOpportunities / totalLeads) * 100) : 0, + conversionFromPrevious: + previousCount > 0 ? Math.round((wonOpportunities / previousCount) * 100) : 0, + }); + + return funnel; + } + + async getSalesPerformance(tenantId: string): Promise { + // Get aggregated performance by user + const result = await this.dataSource.query(` + SELECT + u.id as user_id, + COALESCE(u.first_name || ' ' || u.last_name, u.email) as user_name, + (SELECT COUNT(*) FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1 AND deleted_at IS NULL)::int as leads_assigned, + (SELECT COUNT(*) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL)::int as opportunities_won, + (SELECT COALESCE(SUM(amount), 0) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL) as total_revenue, + (SELECT COUNT(*) FROM sales.activities WHERE assigned_to = u.id AND tenant_id = $1 AND status = 'completed' AND deleted_at IS NULL)::int as activities_completed + FROM users.users u + WHERE u.tenant_id = $1 + AND EXISTS ( + SELECT 1 FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1 + UNION + SELECT 1 FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 + ) + `, [tenantId]); + + return result.map((r: any) => ({ + userId: r.user_id, + userName: r.user_name, + leadsAssigned: r.leads_assigned, + opportunitiesWon: r.opportunities_won, + totalRevenue: Number(r.total_revenue), + conversionRate: + r.leads_assigned > 0 + ? Math.round((r.opportunities_won / r.leads_assigned) * 100) + : 0, + activitiesCompleted: r.activities_completed, + })); + } +}