From 529ea53b5eeb472084511079b37ac1ba76cdfd4a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sat, 24 Jan 2026 20:49:59 -0600 Subject: [PATCH] [SAAS-018] feat: Complete Sales Foundation module implementation ## Backend (NestJS) - Entities: Lead, Opportunity, PipelineStage, Activity with TypeORM - Services: LeadsService, OpportunitiesService, PipelineService, ActivitiesService, SalesDashboardService - Controllers: LeadsController, OpportunitiesController, PipelineController, ActivitiesController, DashboardController - DTOs: Full set of Create/Update/Convert DTOs with validation - Tests: 5 test suites with comprehensive coverage ## Frontend (React) - Pages: /sales, /sales/leads, /sales/leads/[id], /sales/opportunities, /sales/opportunities/[id], /sales/activities - Components: SalesDashboard, ConversionFunnel, LeadsList, LeadForm, LeadCard, PipelineBoard, OpportunityCard, OpportunityForm, ActivityTimeline, ActivityForm - Hooks: useLeads, useOpportunities, usePipeline, useActivities, useSalesDashboard - Services: leads.api, opportunities.api, activities.api, pipeline.api, dashboard.api ## Documentation - Updated SAAS-018-sales.md with implementation details - Updated MASTER_INVENTORY.yml - status changed from specified to completed Story Points: 21 Sprint: 6 - Sales Foundation Co-Authored-By: Claude Opus 4.5 --- apps/backend/src/app.module.ts | 2 + .../__tests__/activities.service.spec.ts | 394 ++++++++++++++++ .../sales/__tests__/leads.controller.spec.ts | 227 ++++++++++ .../sales/__tests__/leads.service.spec.ts | 341 ++++++++++++++ .../opportunities.controller.spec.ts | 326 ++++++++++++++ .../__tests__/opportunities.service.spec.ts | 426 ++++++++++++++++++ .../controllers/activities.controller.ts | 178 ++++++++ .../sales/controllers/dashboard.controller.ts | 91 ++++ .../src/modules/sales/controllers/index.ts | 5 + .../sales/controllers/leads.controller.ts | 143 ++++++ .../controllers/opportunities.controller.ts | 180 ++++++++ .../sales/controllers/pipeline.controller.ts | 117 +++++ .../src/modules/sales/dto/convert-lead.dto.ts | 25 + .../modules/sales/dto/create-activity.dto.ts | 83 ++++ .../src/modules/sales/dto/create-lead.dto.ts | 100 ++++ .../sales/dto/create-opportunity.dto.ts | 87 ++++ apps/backend/src/modules/sales/dto/index.ts | 8 + .../modules/sales/dto/move-opportunity.dto.ts | 19 + .../modules/sales/dto/update-activity.dto.ts | 4 + .../src/modules/sales/dto/update-lead.dto.ts | 4 + .../sales/dto/update-opportunity.dto.ts | 4 + .../modules/sales/entities/activity.entity.ts | 145 ++++++ .../src/modules/sales/entities/index.ts | 4 + .../src/modules/sales/entities/lead.entity.ts | 130 ++++++ .../sales/entities/opportunity.entity.ts | 128 ++++++ .../sales/entities/pipeline-stage.entity.ts | 40 ++ .../backend/src/modules/sales/sales.module.ts | 55 +++ .../sales/services/activities.service.ts | 275 +++++++++++ .../src/modules/sales/services/index.ts | 5 + .../modules/sales/services/leads.service.ts | 235 ++++++++++ .../sales/services/opportunities.service.ts | 342 ++++++++++++++ .../sales/services/pipeline.service.ts | 177 ++++++++ .../sales/services/sales-dashboard.service.ts | 417 +++++++++++++++++ .../src/components/sales/ActivityForm.tsx | 181 ++++++++ .../src/components/sales/ActivityTimeline.tsx | 118 +++++ .../src/components/sales/ConversionFunnel.tsx | 68 +++ .../src/components/sales/LeadCard.tsx | 122 +++++ .../src/components/sales/LeadForm.tsx | 199 ++++++++ .../src/components/sales/LeadsList.tsx | 138 ++++++ .../src/components/sales/OpportunityCard.tsx | 75 +++ .../src/components/sales/OpportunityForm.tsx | 235 ++++++++++ .../src/components/sales/PipelineBoard.tsx | 146 ++++++ .../src/components/sales/SalesDashboard.tsx | 155 +++++++ apps/frontend/src/components/sales/index.ts | 10 + apps/frontend/src/hooks/sales/index.ts | 5 + .../frontend/src/hooks/sales/useActivities.ts | 146 ++++++ apps/frontend/src/hooks/sales/useLeads.ts | 116 +++++ .../src/hooks/sales/useOpportunities.ts | 139 ++++++ apps/frontend/src/hooks/sales/usePipeline.ts | 85 ++++ .../src/hooks/sales/useSalesDashboard.ts | 42 ++ .../src/pages/sales/activities/index.tsx | 204 +++++++++ apps/frontend/src/pages/sales/index.tsx | 30 ++ apps/frontend/src/pages/sales/leads/[id].tsx | 205 +++++++++ apps/frontend/src/pages/sales/leads/index.tsx | 134 ++++++ .../src/pages/sales/opportunities/[id].tsx | 300 ++++++++++++ .../src/pages/sales/opportunities/index.tsx | 93 ++++ .../src/services/sales/activities.api.ts | 156 +++++++ .../src/services/sales/dashboard.api.ts | 108 +++++ apps/frontend/src/services/sales/index.ts | 5 + apps/frontend/src/services/sales/leads.api.ts | 137 ++++++ .../src/services/sales/opportunities.api.ts | 165 +++++++ .../src/services/sales/pipeline.api.ts | 67 +++ database | 2 +- docs/01-modulos/SAAS-018-sales.md | 44 +- .../inventarios/MASTER_INVENTORY.yml | 45 +- 65 files changed, 8362 insertions(+), 30 deletions(-) create mode 100644 apps/backend/src/modules/sales/__tests__/activities.service.spec.ts create mode 100644 apps/backend/src/modules/sales/__tests__/leads.controller.spec.ts create mode 100644 apps/backend/src/modules/sales/__tests__/leads.service.spec.ts create mode 100644 apps/backend/src/modules/sales/__tests__/opportunities.controller.spec.ts create mode 100644 apps/backend/src/modules/sales/__tests__/opportunities.service.spec.ts create mode 100644 apps/backend/src/modules/sales/controllers/activities.controller.ts create mode 100644 apps/backend/src/modules/sales/controllers/dashboard.controller.ts create mode 100644 apps/backend/src/modules/sales/controllers/index.ts create mode 100644 apps/backend/src/modules/sales/controllers/leads.controller.ts create mode 100644 apps/backend/src/modules/sales/controllers/opportunities.controller.ts create mode 100644 apps/backend/src/modules/sales/controllers/pipeline.controller.ts create mode 100644 apps/backend/src/modules/sales/dto/convert-lead.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/create-activity.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/create-lead.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/create-opportunity.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/index.ts create mode 100644 apps/backend/src/modules/sales/dto/move-opportunity.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/update-activity.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/update-lead.dto.ts create mode 100644 apps/backend/src/modules/sales/dto/update-opportunity.dto.ts create mode 100644 apps/backend/src/modules/sales/entities/activity.entity.ts create mode 100644 apps/backend/src/modules/sales/entities/index.ts create mode 100644 apps/backend/src/modules/sales/entities/lead.entity.ts create mode 100644 apps/backend/src/modules/sales/entities/opportunity.entity.ts create mode 100644 apps/backend/src/modules/sales/entities/pipeline-stage.entity.ts create mode 100644 apps/backend/src/modules/sales/sales.module.ts create mode 100644 apps/backend/src/modules/sales/services/activities.service.ts create mode 100644 apps/backend/src/modules/sales/services/index.ts create mode 100644 apps/backend/src/modules/sales/services/leads.service.ts create mode 100644 apps/backend/src/modules/sales/services/opportunities.service.ts create mode 100644 apps/backend/src/modules/sales/services/pipeline.service.ts create mode 100644 apps/backend/src/modules/sales/services/sales-dashboard.service.ts create mode 100644 apps/frontend/src/components/sales/ActivityForm.tsx create mode 100644 apps/frontend/src/components/sales/ActivityTimeline.tsx create mode 100644 apps/frontend/src/components/sales/ConversionFunnel.tsx create mode 100644 apps/frontend/src/components/sales/LeadCard.tsx create mode 100644 apps/frontend/src/components/sales/LeadForm.tsx create mode 100644 apps/frontend/src/components/sales/LeadsList.tsx create mode 100644 apps/frontend/src/components/sales/OpportunityCard.tsx create mode 100644 apps/frontend/src/components/sales/OpportunityForm.tsx create mode 100644 apps/frontend/src/components/sales/PipelineBoard.tsx create mode 100644 apps/frontend/src/components/sales/SalesDashboard.tsx create mode 100644 apps/frontend/src/components/sales/index.ts create mode 100644 apps/frontend/src/hooks/sales/index.ts create mode 100644 apps/frontend/src/hooks/sales/useActivities.ts create mode 100644 apps/frontend/src/hooks/sales/useLeads.ts create mode 100644 apps/frontend/src/hooks/sales/useOpportunities.ts create mode 100644 apps/frontend/src/hooks/sales/usePipeline.ts create mode 100644 apps/frontend/src/hooks/sales/useSalesDashboard.ts create mode 100644 apps/frontend/src/pages/sales/activities/index.tsx create mode 100644 apps/frontend/src/pages/sales/index.tsx create mode 100644 apps/frontend/src/pages/sales/leads/[id].tsx create mode 100644 apps/frontend/src/pages/sales/leads/index.tsx create mode 100644 apps/frontend/src/pages/sales/opportunities/[id].tsx create mode 100644 apps/frontend/src/pages/sales/opportunities/index.tsx create mode 100644 apps/frontend/src/services/sales/activities.api.ts create mode 100644 apps/frontend/src/services/sales/dashboard.api.ts create mode 100644 apps/frontend/src/services/sales/index.ts create mode 100644 apps/frontend/src/services/sales/leads.api.ts create mode 100644 apps/frontend/src/services/sales/opportunities.api.ts create mode 100644 apps/frontend/src/services/sales/pipeline.api.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 9b01118d..be64677c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -26,6 +26,7 @@ import { WebhooksModule } from '@modules/webhooks/webhooks.module'; import { EmailModule } from '@modules/email/email.module'; import { OnboardingModule } from '@modules/onboarding/onboarding.module'; import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module'; +import { SalesModule } from '@modules/sales/sales.module'; @Module({ imports: [ @@ -84,6 +85,7 @@ import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module'; EmailModule, OnboardingModule, WhatsAppModule, + SalesModule, ], }) export class AppModule {} diff --git a/apps/backend/src/modules/sales/__tests__/activities.service.spec.ts b/apps/backend/src/modules/sales/__tests__/activities.service.spec.ts new file mode 100644 index 00000000..2baadc82 --- /dev/null +++ b/apps/backend/src/modules/sales/__tests__/activities.service.spec.ts @@ -0,0 +1,394 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { ActivitiesService } from '../services/activities.service'; +import { Activity, ActivityType, ActivityStatus } from '../entities/activity.entity'; + +describe('ActivitiesService', () => { + let service: ActivitiesService; + let activityRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockActivity: Partial = { + id: 'act-001', + tenant_id: mockTenantId, + type: ActivityType.CALL, + subject: 'Follow up call', + description: 'Discuss pricing options', + lead_id: 'lead-001', + opportunity_id: null, + due_date: new Date('2026-01-15'), + status: ActivityStatus.PENDING, + assigned_to: mockUserId, + created_by: mockUserId, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + completed_at: null, + deleted_at: null, + }; + + beforeEach(async () => { + const mockActivityRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + getMany: jest.fn().mockResolvedValue([mockActivity]), + })), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivitiesService, + { provide: getRepositoryToken(Activity), useValue: mockActivityRepo }, + ], + }).compile(); + + service = module.get(ActivitiesService); + activityRepo = module.get(getRepositoryToken(Activity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== FindAll Tests ==================== + + describe('findAll', () => { + it('should return paginated activities', async () => { + const result = await service.findAll(mockTenantId); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should apply filters', async () => { + const filters = { type: ActivityType.CALL, status: ActivityStatus.PENDING }; + await service.findAll(mockTenantId, filters); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should apply pagination options', async () => { + const pagination = { page: 2, limit: 10, sortBy: 'due_date', sortOrder: 'ASC' as const }; + await service.findAll(mockTenantId, {}, pagination); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + // ==================== FindOne Tests ==================== + + describe('findOne', () => { + it('should return an activity by id', async () => { + activityRepo.findOne.mockResolvedValue(mockActivity as Activity); + + const result = await service.findOne(mockTenantId, 'act-001'); + + expect(result).toEqual(mockActivity); + expect(activityRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'act-001', tenant_id: mockTenantId, deleted_at: undefined }, + relations: ['lead', 'opportunity', 'assignedUser', 'createdByUser'], + }); + }); + + it('should throw NotFoundException if activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Create Tests ==================== + + describe('create', () => { + it('should create an activity successfully', async () => { + const dto = { + type: ActivityType.CALL, + subject: 'Follow up call', + lead_id: 'lead-001', + }; + + activityRepo.create.mockReturnValue(mockActivity as Activity); + activityRepo.save.mockResolvedValue(mockActivity as Activity); + + const result = await service.create(mockTenantId, dto as any, mockUserId); + + expect(result).toEqual(mockActivity); + expect(activityRepo.create).toHaveBeenCalledWith({ + ...dto, + tenant_id: mockTenantId, + created_by: mockUserId, + }); + }); + + it('should throw BadRequestException if no lead or opportunity linked', async () => { + const dto = { + type: ActivityType.CALL, + subject: 'Follow up call', + // No lead_id or opportunity_id + }; + + await expect(service.create(mockTenantId, dto as any, mockUserId)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ==================== Update Tests ==================== + + describe('update', () => { + it('should update an activity successfully', async () => { + const dto = { subject: 'Updated subject' }; + const updatedActivity = { ...mockActivity, subject: 'Updated subject' }; + + activityRepo.findOne.mockResolvedValue(mockActivity as Activity); + activityRepo.save.mockResolvedValue(updatedActivity as Activity); + + const result = await service.update(mockTenantId, 'act-001', dto as any); + + expect(result.subject).toBe('Updated subject'); + }); + + it('should throw BadRequestException for completed activity', async () => { + activityRepo.findOne.mockResolvedValue({ + ...mockActivity, + status: ActivityStatus.COMPLETED, + } as Activity); + + await expect( + service.update(mockTenantId, 'act-001', { subject: 'Updated' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { subject: 'Updated' } as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Remove Tests ==================== + + describe('remove', () => { + it('should soft delete an activity', async () => { + activityRepo.findOne.mockResolvedValue(mockActivity as Activity); + activityRepo.save.mockResolvedValue({ ...mockActivity, deleted_at: new Date() } as Activity); + + await service.remove(mockTenantId, 'act-001'); + + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deleted_at: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException if activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Find By Lead Tests ==================== + + describe('findByLead', () => { + it('should return activities for a lead', async () => { + activityRepo.find.mockResolvedValue([mockActivity as Activity]); + + const result = await service.findByLead(mockTenantId, 'lead-001'); + + expect(result).toHaveLength(1); + expect(activityRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId, lead_id: 'lead-001', deleted_at: undefined }, + relations: ['assignedUser'], + order: { created_at: 'DESC' }, + }); + }); + }); + + // ==================== Find By Opportunity Tests ==================== + + describe('findByOpportunity', () => { + it('should return activities for an opportunity', async () => { + const oppActivity = { ...mockActivity, lead_id: null, opportunity_id: 'opp-001' }; + activityRepo.find.mockResolvedValue([oppActivity as Activity]); + + const result = await service.findByOpportunity(mockTenantId, 'opp-001'); + + expect(result).toHaveLength(1); + expect(activityRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId, opportunity_id: 'opp-001', deleted_at: undefined }, + relations: ['assignedUser'], + order: { created_at: 'DESC' }, + }); + }); + }); + + // ==================== Get Upcoming Tests ==================== + + describe('getUpcoming', () => { + it('should return upcoming activities', async () => { + const result = await service.getUpcoming(mockTenantId); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should filter by user when provided', async () => { + await service.getUpcoming(mockTenantId, mockUserId, 7); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + // ==================== Get Overdue Tests ==================== + + describe('getOverdue', () => { + it('should return overdue activities', async () => { + const result = await service.getOverdue(mockTenantId); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should filter by user when provided', async () => { + await service.getOverdue(mockTenantId, mockUserId); + + expect(activityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + // ==================== Mark As Completed Tests ==================== + + describe('markAsCompleted', () => { + it('should mark activity as completed', async () => { + const completedActivity = { + ...mockActivity, + status: ActivityStatus.COMPLETED, + completed_at: new Date(), + outcome: 'Successful call', + }; + + activityRepo.findOne.mockResolvedValue(mockActivity as Activity); + activityRepo.save.mockResolvedValue(completedActivity as Activity); + + const result = await service.markAsCompleted(mockTenantId, 'act-001', 'Successful call'); + + expect(result.status).toBe(ActivityStatus.COMPLETED); + expect(result.completed_at).toBeDefined(); + expect(result.outcome).toBe('Successful call'); + }); + + it('should throw BadRequestException if already completed', async () => { + activityRepo.findOne.mockResolvedValue({ + ...mockActivity, + status: ActivityStatus.COMPLETED, + } as Activity); + + await expect(service.markAsCompleted(mockTenantId, 'act-001')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsCompleted(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Mark As Cancelled Tests ==================== + + describe('markAsCancelled', () => { + it('should mark activity as cancelled', async () => { + const cancelledActivity = { + ...mockActivity, + status: ActivityStatus.CANCELLED, + }; + + activityRepo.findOne.mockResolvedValue(mockActivity as Activity); + activityRepo.save.mockResolvedValue(cancelledActivity as Activity); + + const result = await service.markAsCancelled(mockTenantId, 'act-001'); + + expect(result.status).toBe(ActivityStatus.CANCELLED); + }); + + it('should throw BadRequestException if not pending', async () => { + activityRepo.findOne.mockResolvedValue({ + ...mockActivity, + status: ActivityStatus.COMPLETED, + } as Activity); + + await expect(service.markAsCancelled(mockTenantId, 'act-001')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsCancelled(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Get Stats Tests ==================== + + describe('getStats', () => { + it('should return activity statistics', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { ...mockActivity, status: ActivityStatus.PENDING, type: ActivityType.CALL, due_date: new Date('2026-01-01') }, + { ...mockActivity, id: 'act-002', status: ActivityStatus.COMPLETED, type: ActivityType.CALL }, + { ...mockActivity, id: 'act-003', status: ActivityStatus.PENDING, type: ActivityType.MEETING, due_date: new Date('2026-01-01') }, + ]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getStats(mockTenantId); + + expect(result.total).toBe(3); + expect(result.pending).toBe(2); + expect(result.completed).toBe(1); + expect(result.byType[ActivityType.CALL]).toBe(2); + expect(result.byType[ActivityType.MEETING]).toBe(1); + }); + + it('should filter by user when provided', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockActivity]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.getStats(mockTenantId, mockUserId); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'activity.assigned_to = :userId', + { userId: mockUserId }, + ); + }); + }); +}); diff --git a/apps/backend/src/modules/sales/__tests__/leads.controller.spec.ts b/apps/backend/src/modules/sales/__tests__/leads.controller.spec.ts new file mode 100644 index 00000000..08383ef7 --- /dev/null +++ b/apps/backend/src/modules/sales/__tests__/leads.controller.spec.ts @@ -0,0 +1,227 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { LeadsController } from '../controllers/leads.controller'; +import { LeadsService } from '../services/leads.service'; +import { Lead, LeadStatus, LeadSource } from '../entities/lead.entity'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; + +describe('LeadsController', () => { + let controller: LeadsController; + let service: jest.Mocked; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-123'; + + const mockLead: Partial = { + id: 'lead-123', + tenant_id: mockTenantId, + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + status: LeadStatus.NEW, + source: LeadSource.WEBSITE, + score: 50, + }; + + const mockOpportunity: Partial = { + id: 'opp-123', + tenant_id: mockTenantId, + name: 'New Deal', + stage: OpportunityStage.PROSPECTING, + amount: 10000, + }; + + const mockStats = { + total: 10, + byStatus: { [LeadStatus.NEW]: 5, [LeadStatus.QUALIFIED]: 3, [LeadStatus.CONVERTED]: 2 }, + bySource: { [LeadSource.WEBSITE]: 6, [LeadSource.REFERRAL]: 4 }, + avgScore: 65, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LeadsController], + providers: [ + { + provide: LeadsService, + useValue: { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + convertToOpportunity: jest.fn(), + assignTo: jest.fn(), + updateScore: jest.fn(), + getStats: jest.fn(), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(LeadsController); + service = module.get(LeadsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== FindAll Tests ==================== + + describe('findAll', () => { + it('should return paginated leads', async () => { + const result = { data: [mockLead], total: 1, page: 1, limit: 20, totalPages: 1 }; + service.findAll.mockResolvedValue(result as any); + + const response = await controller.findAll(mockTenantId); + + expect(response).toEqual(result); + expect(service.findAll).toHaveBeenCalledWith( + mockTenantId, + { status: undefined, source: undefined, assigned_to: undefined, search: undefined }, + { page: undefined, limit: undefined, sortBy: undefined, sortOrder: undefined }, + ); + }); + + it('should pass filters and pagination options', async () => { + const result = { data: [mockLead], total: 1, page: 2, limit: 10, totalPages: 1 }; + service.findAll.mockResolvedValue(result as any); + + const response = await controller.findAll( + mockTenantId, + 2, + 10, + LeadStatus.NEW, + LeadSource.WEBSITE, + mockUserId, + 'john', + 'created_at', + 'DESC', + ); + + expect(response).toEqual(result); + expect(service.findAll).toHaveBeenCalledWith( + mockTenantId, + { status: LeadStatus.NEW, source: LeadSource.WEBSITE, assigned_to: mockUserId, search: 'john' }, + { page: 2, limit: 10, sortBy: 'created_at', sortOrder: 'DESC' }, + ); + }); + }); + + // ==================== GetStats Tests ==================== + + describe('getStats', () => { + it('should return lead statistics', async () => { + service.getStats.mockResolvedValue(mockStats as any); + + const response = await controller.getStats(mockTenantId); + + expect(response).toEqual(mockStats); + expect(service.getStats).toHaveBeenCalledWith(mockTenantId); + }); + }); + + // ==================== FindOne Tests ==================== + + describe('findOne', () => { + it('should return a lead by id', async () => { + service.findOne.mockResolvedValue(mockLead as Lead); + + const response = await controller.findOne(mockTenantId, 'lead-123'); + + expect(response).toEqual(mockLead); + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, 'lead-123'); + }); + }); + + // ==================== Create Tests ==================== + + describe('create', () => { + it('should create a lead', async () => { + const createDto = { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + source: LeadSource.WEBSITE, + }; + service.create.mockResolvedValue(mockLead as Lead); + + const response = await controller.create(mockTenantId, mockUserId, createDto as any); + + expect(response).toEqual(mockLead); + expect(service.create).toHaveBeenCalledWith(mockTenantId, createDto, mockUserId); + }); + }); + + // ==================== Update Tests ==================== + + describe('update', () => { + it('should update a lead', async () => { + const updateDto = { first_name: 'Jane' }; + const updatedLead = { ...mockLead, first_name: 'Jane' }; + service.update.mockResolvedValue(updatedLead as Lead); + + const response = await controller.update(mockTenantId, 'lead-123', updateDto as any); + + expect(response.first_name).toBe('Jane'); + expect(service.update).toHaveBeenCalledWith(mockTenantId, 'lead-123', updateDto); + }); + }); + + // ==================== Remove Tests ==================== + + describe('remove', () => { + it('should delete a lead', async () => { + service.remove.mockResolvedValue(undefined); + + const response = await controller.remove(mockTenantId, 'lead-123'); + + expect(response.message).toBe('Lead deleted successfully'); + expect(service.remove).toHaveBeenCalledWith(mockTenantId, 'lead-123'); + }); + }); + + // ==================== Convert to Opportunity Tests ==================== + + describe('convertToOpportunity', () => { + it('should convert lead to opportunity', async () => { + const convertDto = { opportunity_name: 'New Deal', amount: 10000 }; + service.convertToOpportunity.mockResolvedValue(mockOpportunity as Opportunity); + + const response = await controller.convertToOpportunity(mockTenantId, 'lead-123', convertDto as any); + + expect(response).toEqual(mockOpportunity); + expect(service.convertToOpportunity).toHaveBeenCalledWith(mockTenantId, 'lead-123', convertDto); + }); + }); + + // ==================== Assign To Tests ==================== + + describe('assignTo', () => { + it('should assign lead to user', async () => { + const assignedLead = { ...mockLead, assigned_to: 'user-456' }; + service.assignTo.mockResolvedValue(assignedLead as Lead); + + const response = await controller.assignTo(mockTenantId, 'lead-123', 'user-456'); + + expect(response.assigned_to).toBe('user-456'); + expect(service.assignTo).toHaveBeenCalledWith(mockTenantId, 'lead-123', 'user-456'); + }); + }); + + // ==================== Update Score Tests ==================== + + describe('updateScore', () => { + it('should update lead score', async () => { + const scoredLead = { ...mockLead, score: 80 }; + service.updateScore.mockResolvedValue(scoredLead as Lead); + + const response = await controller.updateScore(mockTenantId, 'lead-123', 80); + + expect(response.score).toBe(80); + expect(service.updateScore).toHaveBeenCalledWith(mockTenantId, 'lead-123', 80); + }); + }); +}); diff --git a/apps/backend/src/modules/sales/__tests__/leads.service.spec.ts b/apps/backend/src/modules/sales/__tests__/leads.service.spec.ts new file mode 100644 index 00000000..01b6ccb5 --- /dev/null +++ b/apps/backend/src/modules/sales/__tests__/leads.service.spec.ts @@ -0,0 +1,341 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { LeadsService } from '../services/leads.service'; +import { Lead, LeadStatus, LeadSource } from '../entities/lead.entity'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; + +describe('LeadsService', () => { + let service: LeadsService; + let leadRepo: jest.Mocked>; + let opportunityRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockLead: Partial = { + id: 'lead-001', + tenant_id: mockTenantId, + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + company: 'Acme Corp', + job_title: 'CEO', + source: LeadSource.WEBSITE, + status: LeadStatus.NEW, + score: 50, + assigned_to: mockUserId, + notes: 'Interested in our product', + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + deleted_at: null, + }; + + const mockOpportunity: Partial = { + id: 'opp-001', + tenant_id: mockTenantId, + name: 'Acme Corp - Opportunity', + lead_id: 'lead-001', + stage: OpportunityStage.PROSPECTING, + amount: 10000, + currency: 'USD', + probability: 20, + }; + + beforeEach(async () => { + const mockLeadRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockLead], 1]), + })), + }; + + const mockOpportunityRepo = { + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LeadsService, + { provide: getRepositoryToken(Lead), useValue: mockLeadRepo }, + { provide: getRepositoryToken(Opportunity), useValue: mockOpportunityRepo }, + ], + }).compile(); + + service = module.get(LeadsService); + leadRepo = module.get(getRepositoryToken(Lead)); + opportunityRepo = module.get(getRepositoryToken(Opportunity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== FindAll Tests ==================== + + describe('findAll', () => { + it('should return paginated leads', async () => { + const result = await service.findAll(mockTenantId); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should apply filters', async () => { + const filters = { status: LeadStatus.NEW, source: LeadSource.WEBSITE }; + await service.findAll(mockTenantId, filters); + + expect(leadRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should apply pagination options', async () => { + const pagination = { page: 2, limit: 10, sortBy: 'created_at', sortOrder: 'DESC' as const }; + await service.findAll(mockTenantId, {}, pagination); + + expect(leadRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + // ==================== FindOne Tests ==================== + + describe('findOne', () => { + it('should return a lead by id', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + + const result = await service.findOne(mockTenantId, 'lead-001'); + + expect(result).toEqual(mockLead); + expect(leadRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'lead-001', tenant_id: mockTenantId, deleted_at: undefined }, + relations: ['assignedUser'], + }); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Create Tests ==================== + + describe('create', () => { + it('should create a lead successfully', async () => { + const dto = { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + source: LeadSource.WEBSITE, + }; + + leadRepo.create.mockReturnValue(mockLead as Lead); + leadRepo.save.mockResolvedValue(mockLead as Lead); + + const result = await service.create(mockTenantId, dto as any, mockUserId); + + expect(result).toEqual(mockLead); + expect(leadRepo.create).toHaveBeenCalledWith({ + ...dto, + tenant_id: mockTenantId, + created_by: mockUserId, + }); + }); + }); + + // ==================== Update Tests ==================== + + describe('update', () => { + it('should update a lead successfully', async () => { + const dto = { first_name: 'Jane' }; + const updatedLead = { ...mockLead, first_name: 'Jane' }; + + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + leadRepo.save.mockResolvedValue(updatedLead as Lead); + + const result = await service.update(mockTenantId, 'lead-001', dto as any); + + expect(result.first_name).toBe('Jane'); + }); + + it('should throw BadRequestException for converted lead', async () => { + leadRepo.findOne.mockResolvedValue({ + ...mockLead, + status: LeadStatus.CONVERTED, + } as Lead); + + await expect( + service.update(mockTenantId, 'lead-001', { first_name: 'Jane' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { first_name: 'Jane' } as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Remove Tests ==================== + + describe('remove', () => { + it('should soft delete a lead', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + leadRepo.save.mockResolvedValue({ ...mockLead, deleted_at: new Date() } as Lead); + + await service.remove(mockTenantId, 'lead-001'); + + expect(leadRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deleted_at: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Convert to Opportunity Tests ==================== + + describe('convertToOpportunity', () => { + it('should convert lead to opportunity successfully', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + opportunityRepo.create.mockReturnValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(mockOpportunity as Opportunity); + leadRepo.save.mockResolvedValue({ + ...mockLead, + status: LeadStatus.CONVERTED, + converted_at: new Date(), + } as Lead); + + const dto = { opportunity_name: 'New Deal', amount: 10000, currency: 'USD' }; + const result = await service.convertToOpportunity(mockTenantId, 'lead-001', dto as any); + + expect(result).toEqual(mockOpportunity); + expect(leadRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: LeadStatus.CONVERTED }), + ); + }); + + it('should throw BadRequestException if lead already converted', async () => { + leadRepo.findOne.mockResolvedValue({ + ...mockLead, + status: LeadStatus.CONVERTED, + } as Lead); + + await expect( + service.convertToOpportunity(mockTenantId, 'lead-001', {} as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect( + service.convertToOpportunity(mockTenantId, 'invalid-id', {} as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Assign To Tests ==================== + + describe('assignTo', () => { + it('should assign lead to user', async () => { + const newUserId = 'user-002'; + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + leadRepo.save.mockResolvedValue({ ...mockLead, assigned_to: newUserId } as Lead); + + const result = await service.assignTo(mockTenantId, 'lead-001', newUserId); + + expect(result.assigned_to).toBe(newUserId); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.assignTo(mockTenantId, 'invalid-id', 'user-002')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Update Score Tests ==================== + + describe('updateScore', () => { + it('should update lead score', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as Lead); + leadRepo.save.mockResolvedValue({ ...mockLead, score: 80 } as Lead); + + const result = await service.updateScore(mockTenantId, 'lead-001', 80); + + expect(result.score).toBe(80); + }); + + it('should throw BadRequestException for invalid score', async () => { + await expect(service.updateScore(mockTenantId, 'lead-001', -10)).rejects.toThrow( + BadRequestException, + ); + + await expect(service.updateScore(mockTenantId, 'lead-001', 150)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.updateScore(mockTenantId, 'invalid-id', 50)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Get Stats Tests ==================== + + describe('getStats', () => { + it('should return lead statistics', async () => { + const leads = [ + { ...mockLead, status: LeadStatus.NEW, source: LeadSource.WEBSITE, score: 50 }, + { ...mockLead, id: 'lead-002', status: LeadStatus.QUALIFIED, source: LeadSource.REFERRAL, score: 70 }, + { ...mockLead, id: 'lead-003', status: LeadStatus.NEW, source: LeadSource.WEBSITE, score: 30 }, + ]; + + leadRepo.find.mockResolvedValue(leads as Lead[]); + + const result = await service.getStats(mockTenantId); + + expect(result.total).toBe(3); + expect(result.byStatus[LeadStatus.NEW]).toBe(2); + expect(result.byStatus[LeadStatus.QUALIFIED]).toBe(1); + expect(result.bySource[LeadSource.WEBSITE]).toBe(2); + expect(result.bySource[LeadSource.REFERRAL]).toBe(1); + expect(result.avgScore).toBe(50); + }); + + it('should return zero avgScore when no leads', async () => { + leadRepo.find.mockResolvedValue([]); + + const result = await service.getStats(mockTenantId); + + expect(result.total).toBe(0); + expect(result.avgScore).toBe(0); + }); + }); +}); diff --git a/apps/backend/src/modules/sales/__tests__/opportunities.controller.spec.ts b/apps/backend/src/modules/sales/__tests__/opportunities.controller.spec.ts new file mode 100644 index 00000000..348d8a6f --- /dev/null +++ b/apps/backend/src/modules/sales/__tests__/opportunities.controller.spec.ts @@ -0,0 +1,326 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { OpportunitiesController } from '../controllers/opportunities.controller'; +import { OpportunitiesService } from '../services/opportunities.service'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; + +describe('OpportunitiesController', () => { + let controller: OpportunitiesController; + let service: jest.Mocked; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-123'; + + const mockOpportunity: Partial = { + id: 'opp-123', + tenant_id: mockTenantId, + name: 'Big Deal', + stage: OpportunityStage.PROSPECTING, + amount: 50000, + currency: 'USD', + probability: 20, + contact_name: 'John Doe', + company_name: 'Acme Corp', + }; + + const mockStats = { + total: 10, + open: 6, + won: 3, + lost: 1, + totalValue: 500000, + wonValue: 200000, + avgDealSize: 66667, + winRate: 75, + }; + + const mockPipelineView = [ + { + stage: OpportunityStage.PROSPECTING, + stageName: 'Prospecting', + opportunities: [mockOpportunity], + count: 1, + totalAmount: 50000, + }, + ]; + + const mockForecast = { + totalPipeline: 100000, + weightedPipeline: 30000, + expectedRevenue: 30000, + byMonth: [{ month: '2026-02', amount: 50000, weighted: 15000 }], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OpportunitiesController], + providers: [ + { + provide: OpportunitiesService, + useValue: { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + moveToStage: jest.fn(), + markAsWon: jest.fn(), + markAsLost: jest.fn(), + getByStage: jest.fn(), + getForecast: jest.fn(), + getStats: jest.fn(), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(OpportunitiesController); + service = module.get(OpportunitiesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== FindAll Tests ==================== + + describe('findAll', () => { + it('should return paginated opportunities', async () => { + const result = { data: [mockOpportunity], total: 1, page: 1, limit: 20, totalPages: 1 }; + service.findAll.mockResolvedValue(result as any); + + const response = await controller.findAll(mockTenantId); + + expect(response).toEqual(result); + expect(service.findAll).toHaveBeenCalledWith( + mockTenantId, + expect.any(Object), + expect.any(Object), + ); + }); + + it('should pass filters and pagination options', async () => { + const result = { data: [mockOpportunity], total: 1, page: 2, limit: 10, totalPages: 1 }; + service.findAll.mockResolvedValue(result as any); + + const response = await controller.findAll( + mockTenantId, + 2, + 10, + OpportunityStage.PROSPECTING, + 'stage-001', + mockUserId, + 10000, + 100000, + true, + 'acme', + 'amount', + 'DESC', + ); + + expect(response).toEqual(result); + expect(service.findAll).toHaveBeenCalledWith( + mockTenantId, + { + stage: OpportunityStage.PROSPECTING, + stage_id: 'stage-001', + assigned_to: mockUserId, + min_amount: 10000, + max_amount: 100000, + is_open: true, + search: 'acme', + }, + { page: 2, limit: 10, sortBy: 'amount', sortOrder: 'DESC' }, + ); + }); + }); + + // ==================== GetStats Tests ==================== + + describe('getStats', () => { + it('should return opportunity statistics', async () => { + service.getStats.mockResolvedValue(mockStats as any); + + const response = await controller.getStats(mockTenantId); + + expect(response).toEqual(mockStats); + expect(service.getStats).toHaveBeenCalledWith(mockTenantId); + }); + }); + + // ==================== GetByStage (Pipeline) Tests ==================== + + describe('getByStage', () => { + it('should return opportunities grouped by stage', async () => { + service.getByStage.mockResolvedValue(mockPipelineView as any); + + const response = await controller.getByStage(mockTenantId); + + expect(response).toEqual(mockPipelineView); + expect(service.getByStage).toHaveBeenCalledWith(mockTenantId); + }); + }); + + // ==================== GetForecast Tests ==================== + + describe('getForecast', () => { + it('should return sales forecast', async () => { + service.getForecast.mockResolvedValue(mockForecast as any); + + const response = await controller.getForecast(mockTenantId, '2026-01-01', '2026-06-30'); + + expect(response).toEqual(mockForecast); + expect(service.getForecast).toHaveBeenCalledWith( + mockTenantId, + new Date('2026-01-01'), + new Date('2026-06-30'), + ); + }); + }); + + // ==================== FindOne Tests ==================== + + describe('findOne', () => { + it('should return an opportunity by id', async () => { + service.findOne.mockResolvedValue(mockOpportunity as Opportunity); + + const response = await controller.findOne(mockTenantId, 'opp-123'); + + expect(response).toEqual(mockOpportunity); + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, 'opp-123'); + }); + }); + + // ==================== Create Tests ==================== + + describe('create', () => { + it('should create an opportunity', async () => { + const createDto = { + name: 'Big Deal', + amount: 50000, + currency: 'USD', + stage: OpportunityStage.PROSPECTING, + }; + service.create.mockResolvedValue(mockOpportunity as Opportunity); + + const response = await controller.create(mockTenantId, mockUserId, createDto as any); + + expect(response).toEqual(mockOpportunity); + expect(service.create).toHaveBeenCalledWith(mockTenantId, createDto, mockUserId); + }); + }); + + // ==================== Update Tests ==================== + + describe('update', () => { + it('should update an opportunity', async () => { + const updateDto = { name: 'Updated Deal' }; + const updatedOpp = { ...mockOpportunity, name: 'Updated Deal' }; + service.update.mockResolvedValue(updatedOpp as Opportunity); + + const response = await controller.update(mockTenantId, 'opp-123', updateDto as any); + + expect(response.name).toBe('Updated Deal'); + expect(service.update).toHaveBeenCalledWith(mockTenantId, 'opp-123', updateDto); + }); + }); + + // ==================== Remove Tests ==================== + + describe('remove', () => { + it('should delete an opportunity', async () => { + service.remove.mockResolvedValue(undefined); + + const response = await controller.remove(mockTenantId, 'opp-123'); + + expect(response.message).toBe('Opportunity deleted successfully'); + expect(service.remove).toHaveBeenCalledWith(mockTenantId, 'opp-123'); + }); + }); + + // ==================== Move To Stage Tests ==================== + + describe('moveToStage', () => { + it('should move opportunity to a new stage', async () => { + const moveDto = { stage: OpportunityStage.QUALIFICATION, notes: 'Good progress' }; + const movedOpp = { ...mockOpportunity, stage: OpportunityStage.QUALIFICATION }; + service.moveToStage.mockResolvedValue(movedOpp as Opportunity); + + const response = await controller.moveToStage(mockTenantId, 'opp-123', moveDto as any); + + expect(response.stage).toBe(OpportunityStage.QUALIFICATION); + expect(service.moveToStage).toHaveBeenCalledWith(mockTenantId, 'opp-123', moveDto); + }); + }); + + // ==================== Mark As Won Tests ==================== + + describe('markAsWon', () => { + it('should mark opportunity as won', async () => { + const wonOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_WON, + won_at: new Date(), + probability: 100, + }; + service.markAsWon.mockResolvedValue(wonOpp as Opportunity); + + const response = await controller.markAsWon(mockTenantId, 'opp-123', 'Great close!'); + + expect(response.stage).toBe(OpportunityStage.CLOSED_WON); + expect(service.markAsWon).toHaveBeenCalledWith(mockTenantId, 'opp-123', 'Great close!'); + }); + + it('should mark opportunity as won without notes', async () => { + const wonOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_WON, + won_at: new Date(), + probability: 100, + }; + service.markAsWon.mockResolvedValue(wonOpp as Opportunity); + + const response = await controller.markAsWon(mockTenantId, 'opp-123'); + + expect(response.stage).toBe(OpportunityStage.CLOSED_WON); + expect(service.markAsWon).toHaveBeenCalledWith(mockTenantId, 'opp-123', undefined); + }); + }); + + // ==================== Mark As Lost Tests ==================== + + describe('markAsLost', () => { + it('should mark opportunity as lost', async () => { + const lostOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_LOST, + lost_at: new Date(), + probability: 0, + lost_reason: 'Budget constraints', + }; + service.markAsLost.mockResolvedValue(lostOpp as Opportunity); + + const response = await controller.markAsLost(mockTenantId, 'opp-123', 'Budget constraints'); + + expect(response.stage).toBe(OpportunityStage.CLOSED_LOST); + expect(response.lost_reason).toBe('Budget constraints'); + expect(service.markAsLost).toHaveBeenCalledWith(mockTenantId, 'opp-123', 'Budget constraints'); + }); + + it('should mark opportunity as lost without reason', async () => { + const lostOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_LOST, + lost_at: new Date(), + probability: 0, + }; + service.markAsLost.mockResolvedValue(lostOpp as Opportunity); + + const response = await controller.markAsLost(mockTenantId, 'opp-123'); + + expect(response.stage).toBe(OpportunityStage.CLOSED_LOST); + expect(service.markAsLost).toHaveBeenCalledWith(mockTenantId, 'opp-123', undefined); + }); + }); +}); diff --git a/apps/backend/src/modules/sales/__tests__/opportunities.service.spec.ts b/apps/backend/src/modules/sales/__tests__/opportunities.service.spec.ts new file mode 100644 index 00000000..e9fbd43f --- /dev/null +++ b/apps/backend/src/modules/sales/__tests__/opportunities.service.spec.ts @@ -0,0 +1,426 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { OpportunitiesService } from '../services/opportunities.service'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; +import { PipelineStage } from '../entities/pipeline-stage.entity'; + +describe('OpportunitiesService', () => { + let service: OpportunitiesService; + let opportunityRepo: jest.Mocked>; + let pipelineStageRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockOpportunity: Partial = { + id: 'opp-001', + tenant_id: mockTenantId, + name: 'Big Deal', + lead_id: 'lead-001', + stage: OpportunityStage.PROSPECTING, + amount: 50000, + currency: 'USD', + probability: 20, + expected_close_date: new Date('2026-03-01'), + contact_name: 'John Doe', + contact_email: 'john@example.com', + company_name: 'Acme Corp', + assigned_to: mockUserId, + notes: 'Important deal', + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + won_at: null, + lost_at: null, + deleted_at: null, + }; + + beforeEach(async () => { + const mockOpportunityRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockOpportunity], 1]), + })), + }; + + const mockPipelineStageRepo = { + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OpportunitiesService, + { provide: getRepositoryToken(Opportunity), useValue: mockOpportunityRepo }, + { provide: getRepositoryToken(PipelineStage), useValue: mockPipelineStageRepo }, + ], + }).compile(); + + service = module.get(OpportunitiesService); + opportunityRepo = module.get(getRepositoryToken(Opportunity)); + pipelineStageRepo = module.get(getRepositoryToken(PipelineStage)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== FindAll Tests ==================== + + describe('findAll', () => { + it('should return paginated opportunities', async () => { + const result = await service.findAll(mockTenantId); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should apply filters', async () => { + const filters = { stage: OpportunityStage.PROSPECTING, is_open: true }; + await service.findAll(mockTenantId, filters); + + expect(opportunityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should apply pagination options', async () => { + const pagination = { page: 2, limit: 10, sortBy: 'amount', sortOrder: 'DESC' as const }; + await service.findAll(mockTenantId, {}, pagination); + + expect(opportunityRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + // ==================== FindOne Tests ==================== + + describe('findOne', () => { + it('should return an opportunity by id', async () => { + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + + const result = await service.findOne(mockTenantId, 'opp-001'); + + expect(result).toEqual(mockOpportunity); + expect(opportunityRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'opp-001', tenant_id: mockTenantId, deleted_at: undefined }, + relations: ['lead', 'assignedUser', 'pipelineStage'], + }); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Create Tests ==================== + + describe('create', () => { + it('should create an opportunity successfully', async () => { + const dto = { + name: 'Big Deal', + amount: 50000, + currency: 'USD', + stage: OpportunityStage.PROSPECTING, + }; + + opportunityRepo.create.mockReturnValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(mockOpportunity as Opportunity); + + const result = await service.create(mockTenantId, dto as any, mockUserId); + + expect(result).toEqual(mockOpportunity); + expect(opportunityRepo.create).toHaveBeenCalledWith({ + ...dto, + tenant_id: mockTenantId, + created_by: mockUserId, + }); + }); + }); + + // ==================== Update Tests ==================== + + describe('update', () => { + it('should update an opportunity successfully', async () => { + const dto = { name: 'Updated Deal' }; + const updatedOpp = { ...mockOpportunity, name: 'Updated Deal' }; + + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(updatedOpp as Opportunity); + + const result = await service.update(mockTenantId, 'opp-001', dto as any); + + expect(result.name).toBe('Updated Deal'); + }); + + it('should throw BadRequestException for closed opportunity', async () => { + opportunityRepo.findOne.mockResolvedValue({ + ...mockOpportunity, + won_at: new Date(), + } as Opportunity); + + await expect( + service.update(mockTenantId, 'opp-001', { name: 'Updated' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { name: 'Updated' } as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Remove Tests ==================== + + describe('remove', () => { + it('should soft delete an opportunity', async () => { + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue({ ...mockOpportunity, deleted_at: new Date() } as Opportunity); + + await service.remove(mockTenantId, 'opp-001'); + + expect(opportunityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deleted_at: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Move To Stage Tests ==================== + + describe('moveToStage', () => { + it('should move opportunity to a new stage', async () => { + const dto = { stage: OpportunityStage.QUALIFICATION, notes: 'Good progress' }; + const movedOpp = { ...mockOpportunity, stage: OpportunityStage.QUALIFICATION }; + + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(movedOpp as Opportunity); + + const result = await service.moveToStage(mockTenantId, 'opp-001', dto as any); + + expect(result.stage).toBe(OpportunityStage.QUALIFICATION); + }); + + it('should throw BadRequestException for closed opportunity', async () => { + opportunityRepo.findOne.mockResolvedValue({ + ...mockOpportunity, + won_at: new Date(), + } as Opportunity); + + await expect( + service.moveToStage(mockTenantId, 'opp-001', { stage: OpportunityStage.QUALIFICATION } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect( + service.moveToStage(mockTenantId, 'invalid-id', { stage: OpportunityStage.QUALIFICATION } as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Mark As Won Tests ==================== + + describe('markAsWon', () => { + it('should mark opportunity as won', async () => { + const wonOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_WON, + won_at: new Date(), + probability: 100, + }; + + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(wonOpp as Opportunity); + + const result = await service.markAsWon(mockTenantId, 'opp-001', 'Great close!'); + + expect(result.stage).toBe(OpportunityStage.CLOSED_WON); + expect(result.won_at).toBeDefined(); + expect(result.probability).toBe(100); + }); + + it('should throw BadRequestException if already closed', async () => { + opportunityRepo.findOne.mockResolvedValue({ + ...mockOpportunity, + won_at: new Date(), + } as Opportunity); + + await expect(service.markAsWon(mockTenantId, 'opp-001')).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsWon(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Mark As Lost Tests ==================== + + describe('markAsLost', () => { + it('should mark opportunity as lost', async () => { + const lostOpp = { + ...mockOpportunity, + stage: OpportunityStage.CLOSED_LOST, + lost_at: new Date(), + probability: 0, + lost_reason: 'Budget constraints', + }; + + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as Opportunity); + opportunityRepo.save.mockResolvedValue(lostOpp as Opportunity); + + const result = await service.markAsLost(mockTenantId, 'opp-001', 'Budget constraints'); + + expect(result.stage).toBe(OpportunityStage.CLOSED_LOST); + expect(result.lost_at).toBeDefined(); + expect(result.probability).toBe(0); + expect(result.lost_reason).toBe('Budget constraints'); + }); + + it('should throw BadRequestException if already closed', async () => { + opportunityRepo.findOne.mockResolvedValue({ + ...mockOpportunity, + lost_at: new Date(), + } as Opportunity); + + await expect(service.markAsLost(mockTenantId, 'opp-001')).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsLost(mockTenantId, 'invalid-id')).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Get By Stage Tests ==================== + + describe('getByStage', () => { + it('should return opportunities grouped by stage', async () => { + const opportunities = [ + { ...mockOpportunity, stage: OpportunityStage.PROSPECTING, amount: 10000 }, + { ...mockOpportunity, id: 'opp-002', stage: OpportunityStage.PROSPECTING, amount: 20000 }, + { ...mockOpportunity, id: 'opp-003', stage: OpportunityStage.QUALIFICATION, amount: 30000 }, + ]; + + opportunityRepo.find.mockResolvedValue(opportunities as Opportunity[]); + + const result = await service.getByStage(mockTenantId); + + expect(result).toHaveLength(6); // All stages + const prospecting = result.find((s) => s.stage === OpportunityStage.PROSPECTING); + expect(prospecting?.count).toBe(2); + expect(prospecting?.totalAmount).toBe(30000); + }); + }); + + // ==================== Get Forecast Tests ==================== + + describe('getForecast', () => { + it('should return sales forecast', async () => { + const opportunities = [ + { + ...mockOpportunity, + amount: 10000, + probability: 50, + expected_close_date: new Date('2026-02-15'), + }, + { + ...mockOpportunity, + id: 'opp-002', + amount: 20000, + probability: 80, + expected_close_date: new Date('2026-03-15'), + }, + ]; + + opportunityRepo.find.mockResolvedValue(opportunities as Opportunity[]); + + const result = await service.getForecast( + mockTenantId, + new Date('2026-01-01'), + new Date('2026-04-01'), + ); + + expect(result.totalPipeline).toBe(30000); + expect(result.weightedPipeline).toBe(21000); // 10000*0.5 + 20000*0.8 + expect(result.byMonth).toHaveLength(2); + }); + + it('should filter opportunities by date range', async () => { + const opportunities = [ + { + ...mockOpportunity, + amount: 10000, + probability: 50, + expected_close_date: new Date('2026-06-15'), // Outside range + }, + ]; + + opportunityRepo.find.mockResolvedValue(opportunities as Opportunity[]); + + const result = await service.getForecast( + mockTenantId, + new Date('2026-01-01'), + new Date('2026-04-01'), + ); + + expect(result.totalPipeline).toBe(0); + }); + }); + + // ==================== Get Stats Tests ==================== + + describe('getStats', () => { + it('should return opportunity statistics', async () => { + const opportunities = [ + { ...mockOpportunity, amount: 10000, won_at: null, lost_at: null }, + { ...mockOpportunity, id: 'opp-002', amount: 20000, won_at: new Date(), lost_at: null }, + { ...mockOpportunity, id: 'opp-003', amount: 15000, won_at: new Date(), lost_at: null }, + { ...mockOpportunity, id: 'opp-004', amount: 5000, won_at: null, lost_at: new Date() }, + ]; + + opportunityRepo.find.mockResolvedValue(opportunities as Opportunity[]); + + const result = await service.getStats(mockTenantId); + + expect(result.total).toBe(4); + expect(result.open).toBe(1); + expect(result.won).toBe(2); + expect(result.lost).toBe(1); + expect(result.totalValue).toBe(50000); + expect(result.wonValue).toBe(35000); + expect(result.winRate).toBe(67); // 2/(2+1) * 100 rounded + }); + + it('should return zero winRate when no closed opportunities', async () => { + opportunityRepo.find.mockResolvedValue([mockOpportunity] as Opportunity[]); + + const result = await service.getStats(mockTenantId); + + expect(result.winRate).toBe(0); + }); + }); +}); diff --git a/apps/backend/src/modules/sales/controllers/activities.controller.ts b/apps/backend/src/modules/sales/controllers/activities.controller.ts new file mode 100644 index 00000000..20f085ff --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/activities.controller.ts @@ -0,0 +1,178 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { ActivitiesService, ActivityFilters, PaginationOptions } from '../services/activities.service'; +import { CreateActivityDto, UpdateActivityDto } from '../dto'; +import { ActivityType, ActivityStatus } from '../entities/activity.entity'; + +@ApiTags('Sales - Activities') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales/activities') +export class ActivitiesController { + constructor(private readonly activitiesService: ActivitiesService) {} + + @Get() + @ApiOperation({ summary: 'Get all activities' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'type', required: false, enum: ActivityType }) + @ApiQuery({ name: 'status', required: false, enum: ActivityStatus }) + @ApiQuery({ name: 'lead_id', required: false, type: String }) + @ApiQuery({ name: 'opportunity_id', required: false, type: String }) + @ApiQuery({ name: 'assigned_to', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('type') type?: ActivityType, + @Query('status') status?: ActivityStatus, + @Query('lead_id') lead_id?: string, + @Query('opportunity_id') opportunity_id?: string, + @Query('assigned_to') assigned_to?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: ActivityFilters = { type, status, lead_id, opportunity_id, assigned_to }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.activitiesService.findAll(tenantId, filters, pagination); + } + + @Get('stats') + @ApiOperation({ summary: 'Get activity statistics' }) + @ApiQuery({ name: 'user_id', required: false, type: String }) + async getStats( + @CurrentTenant() tenantId: string, + @Query('user_id') userId?: string, + ) { + return this.activitiesService.getStats(tenantId, userId); + } + + @Get('upcoming') + @ApiOperation({ summary: 'Get upcoming activities' }) + @ApiQuery({ name: 'days', required: false, type: Number, description: 'Days to look ahead (default: 7)' }) + @ApiQuery({ name: 'user_id', required: false, type: String }) + async getUpcoming( + @CurrentTenant() tenantId: string, + @Query('days') days?: number, + @Query('user_id') userId?: string, + ) { + return this.activitiesService.getUpcoming(tenantId, userId, days); + } + + @Get('overdue') + @ApiOperation({ summary: 'Get overdue activities' }) + @ApiQuery({ name: 'user_id', required: false, type: String }) + async getOverdue( + @CurrentTenant() tenantId: string, + @Query('user_id') userId?: string, + ) { + return this.activitiesService.getOverdue(tenantId, userId); + } + + @Get('lead/:leadId') + @ApiOperation({ summary: 'Get activities for a lead' }) + async findByLead( + @CurrentTenant() tenantId: string, + @Param('leadId', ParseUUIDPipe) leadId: string, + ) { + return this.activitiesService.findByLead(tenantId, leadId); + } + + @Get('opportunity/:opportunityId') + @ApiOperation({ summary: 'Get activities for an opportunity' }) + async findByOpportunity( + @CurrentTenant() tenantId: string, + @Param('opportunityId', ParseUUIDPipe) opportunityId: string, + ) { + return this.activitiesService.findByOpportunity(tenantId, opportunityId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an activity by ID' }) + @ApiResponse({ status: 200, description: 'Activity found' }) + @ApiResponse({ status: 404, description: 'Activity not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.activitiesService.findOne(tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new activity' }) + @ApiResponse({ status: 201, description: 'Activity created' }) + @ApiResponse({ status: 400, description: 'Activity must be linked to a lead or opportunity' }) + async create( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateActivityDto, + ) { + return this.activitiesService.create(tenantId, dto, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an activity' }) + @ApiResponse({ status: 200, description: 'Activity updated' }) + @ApiResponse({ status: 400, description: 'Cannot update completed activity' }) + @ApiResponse({ status: 404, description: 'Activity not found' }) + async update( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateActivityDto, + ) { + return this.activitiesService.update(tenantId, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an activity' }) + @ApiResponse({ status: 200, description: 'Activity deleted' }) + @ApiResponse({ status: 404, description: 'Activity not found' }) + async remove( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.activitiesService.remove(tenantId, id); + return { message: 'Activity deleted successfully' }; + } + + @Post(':id/complete') + @ApiOperation({ summary: 'Mark activity as completed' }) + @ApiResponse({ status: 200, description: 'Activity completed' }) + @ApiResponse({ status: 400, description: 'Activity already completed' }) + @ApiResponse({ status: 404, description: 'Activity not found' }) + async markAsCompleted( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('outcome') outcome?: string, + ) { + return this.activitiesService.markAsCompleted(tenantId, id, outcome); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel an activity' }) + @ApiResponse({ status: 200, description: 'Activity cancelled' }) + @ApiResponse({ status: 400, description: 'Only pending activities can be cancelled' }) + @ApiResponse({ status: 404, description: 'Activity not found' }) + async markAsCancelled( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.activitiesService.markAsCancelled(tenantId, id); + } +} diff --git a/apps/backend/src/modules/sales/controllers/dashboard.controller.ts b/apps/backend/src/modules/sales/controllers/dashboard.controller.ts new file mode 100644 index 00000000..a9a6a220 --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/dashboard.controller.ts @@ -0,0 +1,91 @@ +import { + Controller, + Get, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { SalesDashboardService } from '../services/sales-dashboard.service'; + +@ApiTags('Sales - Dashboard') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales/dashboard') +export class DashboardController { + constructor(private readonly dashboardService: SalesDashboardService) {} + + @Get() + @ApiOperation({ summary: 'Get sales dashboard summary' }) + @ApiResponse({ status: 200, description: 'Dashboard data retrieved' }) + async getSummary(@CurrentTenant() tenantId: string) { + return this.dashboardService.getSummary(tenantId); + } + + @Get('conversion') + @ApiOperation({ summary: 'Get conversion rates' }) + @ApiQuery({ name: 'start_date', required: false, type: String }) + @ApiQuery({ name: 'end_date', required: false, type: String }) + async getConversionRates( + @CurrentTenant() tenantId: string, + @Query('start_date') startDate?: string, + @Query('end_date') endDate?: string, + ) { + return this.dashboardService.getConversionRates( + tenantId, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + ); + } + + @Get('revenue') + @ApiOperation({ summary: 'Get revenue by period' }) + @ApiQuery({ name: 'start_date', required: true, type: String }) + @ApiQuery({ name: 'end_date', required: true, type: String }) + async getRevenueByPeriod( + @CurrentTenant() tenantId: string, + @Query('start_date') startDate: string, + @Query('end_date') endDate: string, + ) { + return this.dashboardService.getRevenueByPeriod( + tenantId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('top-sellers') + @ApiOperation({ summary: 'Get top sellers' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of top sellers to return (default: 10)' }) + @ApiQuery({ name: 'start_date', required: false, type: String }) + @ApiQuery({ name: 'end_date', required: false, type: String }) + async getTopSellers( + @CurrentTenant() tenantId: string, + @Query('limit') limit?: number, + @Query('start_date') startDate?: string, + @Query('end_date') endDate?: string, + ) { + return this.dashboardService.getTopSellers( + tenantId, + limit, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + ); + } + + @Get('forecast') + @ApiOperation({ summary: 'Get sales forecast' }) + @ApiQuery({ name: 'start_date', required: true, type: String }) + @ApiQuery({ name: 'end_date', required: true, type: String }) + async getForecast( + @CurrentTenant() tenantId: string, + @Query('start_date') startDate: string, + @Query('end_date') endDate: string, + ) { + // Delegate to opportunities service for forecast + const { OpportunitiesService } = await import('../services/opportunities.service'); + // This will be handled by the opportunities controller + return { message: 'Use /sales/opportunities/forecast endpoint' }; + } +} diff --git a/apps/backend/src/modules/sales/controllers/index.ts b/apps/backend/src/modules/sales/controllers/index.ts new file mode 100644 index 00000000..3e2e1106 --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/index.ts @@ -0,0 +1,5 @@ +export * from './leads.controller'; +export * from './opportunities.controller'; +export * from './pipeline.controller'; +export * from './activities.controller'; +export * from './dashboard.controller'; diff --git a/apps/backend/src/modules/sales/controllers/leads.controller.ts b/apps/backend/src/modules/sales/controllers/leads.controller.ts new file mode 100644 index 00000000..4a60daf1 --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/leads.controller.ts @@ -0,0 +1,143 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { LeadsService, LeadFilters, PaginationOptions } from '../services/leads.service'; +import { CreateLeadDto, UpdateLeadDto, ConvertLeadDto } from '../dto'; +import { Lead, LeadStatus, LeadSource } from '../entities/lead.entity'; + +@ApiTags('Sales - Leads') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales/leads') +export class LeadsController { + constructor(private readonly leadsService: LeadsService) {} + + @Get() + @ApiOperation({ summary: 'Get all leads' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'status', required: false, enum: LeadStatus }) + @ApiQuery({ name: 'source', required: false, enum: LeadSource }) + @ApiQuery({ name: 'assigned_to', required: false, type: String }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: LeadStatus, + @Query('source') source?: LeadSource, + @Query('assigned_to') assigned_to?: string, + @Query('search') search?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: LeadFilters = { status, source, assigned_to, search }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.leadsService.findAll(tenantId, filters, pagination); + } + + @Get('stats') + @ApiOperation({ summary: 'Get lead statistics' }) + async getStats(@CurrentTenant() tenantId: string) { + return this.leadsService.getStats(tenantId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a lead by ID' }) + @ApiResponse({ status: 200, description: 'Lead found' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.leadsService.findOne(tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new lead' }) + @ApiResponse({ status: 201, description: 'Lead created' }) + async create( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateLeadDto, + ) { + return this.leadsService.create(tenantId, dto, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a lead' }) + @ApiResponse({ status: 200, description: 'Lead updated' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async update( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateLeadDto, + ) { + return this.leadsService.update(tenantId, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a lead' }) + @ApiResponse({ status: 200, description: 'Lead deleted' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async remove( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.leadsService.remove(tenantId, id); + return { message: 'Lead deleted successfully' }; + } + + @Post(':id/convert') + @ApiOperation({ summary: 'Convert a lead to an opportunity' }) + @ApiResponse({ status: 201, description: 'Lead converted to opportunity' }) + @ApiResponse({ status: 400, description: 'Lead already converted' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async convertToOpportunity( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ConvertLeadDto, + ) { + return this.leadsService.convertToOpportunity(tenantId, id, dto); + } + + @Patch(':id/assign') + @ApiOperation({ summary: 'Assign a lead to a user' }) + @ApiResponse({ status: 200, description: 'Lead assigned' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async assignTo( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('user_id', ParseUUIDPipe) userId: string, + ) { + return this.leadsService.assignTo(tenantId, id, userId); + } + + @Patch(':id/score') + @ApiOperation({ summary: 'Update lead score' }) + @ApiResponse({ status: 200, description: 'Score updated' }) + @ApiResponse({ status: 400, description: 'Invalid score' }) + @ApiResponse({ status: 404, description: 'Lead not found' }) + async updateScore( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('score') score: number, + ) { + return this.leadsService.updateScore(tenantId, id, score); + } +} diff --git a/apps/backend/src/modules/sales/controllers/opportunities.controller.ts b/apps/backend/src/modules/sales/controllers/opportunities.controller.ts new file mode 100644 index 00000000..ada55e52 --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/opportunities.controller.ts @@ -0,0 +1,180 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { OpportunitiesService, OpportunityFilters, PaginationOptions } from '../services/opportunities.service'; +import { CreateOpportunityDto, UpdateOpportunityDto, MoveOpportunityDto } from '../dto'; +import { OpportunityStage } from '../entities/opportunity.entity'; + +@ApiTags('Sales - Opportunities') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales/opportunities') +export class OpportunitiesController { + constructor(private readonly opportunitiesService: OpportunitiesService) {} + + @Get() + @ApiOperation({ summary: 'Get all opportunities' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'stage', required: false, enum: OpportunityStage }) + @ApiQuery({ name: 'stage_id', required: false, type: String }) + @ApiQuery({ name: 'assigned_to', required: false, type: String }) + @ApiQuery({ name: 'min_amount', required: false, type: Number }) + @ApiQuery({ name: 'max_amount', required: false, type: Number }) + @ApiQuery({ name: 'is_open', required: false, type: Boolean }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('stage') stage?: OpportunityStage, + @Query('stage_id') stage_id?: string, + @Query('assigned_to') assigned_to?: string, + @Query('min_amount') min_amount?: number, + @Query('max_amount') max_amount?: number, + @Query('is_open') is_open?: boolean, + @Query('search') search?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: OpportunityFilters = { + stage, + stage_id, + assigned_to, + min_amount, + max_amount, + is_open, + search, + }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.opportunitiesService.findAll(tenantId, filters, pagination); + } + + @Get('stats') + @ApiOperation({ summary: 'Get opportunity statistics' }) + async getStats(@CurrentTenant() tenantId: string) { + return this.opportunitiesService.getStats(tenantId); + } + + @Get('pipeline') + @ApiOperation({ summary: 'Get opportunities grouped by stage (pipeline view)' }) + async getByStage(@CurrentTenant() tenantId: string) { + return this.opportunitiesService.getByStage(tenantId); + } + + @Get('forecast') + @ApiOperation({ summary: 'Get sales forecast' }) + @ApiQuery({ name: 'start_date', required: true, type: String }) + @ApiQuery({ name: 'end_date', required: true, type: String }) + async getForecast( + @CurrentTenant() tenantId: string, + @Query('start_date') startDate: string, + @Query('end_date') endDate: string, + ) { + return this.opportunitiesService.getForecast( + tenantId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an opportunity by ID' }) + @ApiResponse({ status: 200, description: 'Opportunity found' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.opportunitiesService.findOne(tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new opportunity' }) + @ApiResponse({ status: 201, description: 'Opportunity created' }) + async create( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateOpportunityDto, + ) { + return this.opportunitiesService.create(tenantId, dto, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an opportunity' }) + @ApiResponse({ status: 200, description: 'Opportunity updated' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async update( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOpportunityDto, + ) { + return this.opportunitiesService.update(tenantId, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an opportunity' }) + @ApiResponse({ status: 200, description: 'Opportunity deleted' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async remove( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.opportunitiesService.remove(tenantId, id); + return { message: 'Opportunity deleted successfully' }; + } + + @Post(':id/move') + @ApiOperation({ summary: 'Move opportunity to a different stage' }) + @ApiResponse({ status: 200, description: 'Opportunity moved' }) + @ApiResponse({ status: 400, description: 'Cannot move closed opportunity' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async moveToStage( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MoveOpportunityDto, + ) { + return this.opportunitiesService.moveToStage(tenantId, id, dto); + } + + @Post(':id/won') + @ApiOperation({ summary: 'Mark opportunity as won' }) + @ApiResponse({ status: 200, description: 'Opportunity marked as won' }) + @ApiResponse({ status: 400, description: 'Opportunity already closed' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async markAsWon( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('notes') notes?: string, + ) { + return this.opportunitiesService.markAsWon(tenantId, id, notes); + } + + @Post(':id/lost') + @ApiOperation({ summary: 'Mark opportunity as lost' }) + @ApiResponse({ status: 200, description: 'Opportunity marked as lost' }) + @ApiResponse({ status: 400, description: 'Opportunity already closed' }) + @ApiResponse({ status: 404, description: 'Opportunity not found' }) + async markAsLost( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('reason') reason?: string, + ) { + return this.opportunitiesService.markAsLost(tenantId, id, reason); + } +} diff --git a/apps/backend/src/modules/sales/controllers/pipeline.controller.ts b/apps/backend/src/modules/sales/controllers/pipeline.controller.ts new file mode 100644 index 00000000..a35423a8 --- /dev/null +++ b/apps/backend/src/modules/sales/controllers/pipeline.controller.ts @@ -0,0 +1,117 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { PipelineService } from '../services/pipeline.service'; +import { PipelineStage } from '../entities/pipeline-stage.entity'; + +class CreatePipelineStageDto { + name: string; + position?: number; + color?: string; + is_won?: boolean; + is_lost?: boolean; +} + +class UpdatePipelineStageDto { + name?: string; + color?: string; + is_active?: boolean; +} + +class ReorderStagesDto { + stage_ids: string[]; +} + +@ApiTags('Sales - Pipeline') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sales/pipeline') +export class PipelineController { + constructor(private readonly pipelineService: PipelineService) {} + + @Get() + @ApiOperation({ summary: 'Get pipeline overview with opportunity counts' }) + async getPipeline(@CurrentTenant() tenantId: string) { + return this.pipelineService.getStages(tenantId); + } + + @Get('stages') + @ApiOperation({ summary: 'Get all pipeline stages' }) + async getStages(@CurrentTenant() tenantId: string) { + return this.pipelineService.getStages(tenantId); + } + + @Get('stages/:id') + @ApiOperation({ summary: 'Get a pipeline stage by ID' }) + @ApiResponse({ status: 200, description: 'Stage found' }) + @ApiResponse({ status: 404, description: 'Stage not found' }) + async getStage( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.pipelineService.findOne(tenantId, id); + } + + @Post('stages') + @ApiOperation({ summary: 'Create a new pipeline stage' }) + @ApiResponse({ status: 201, description: 'Stage created' }) + async createStage( + @CurrentTenant() tenantId: string, + @Body() dto: CreatePipelineStageDto, + ) { + return this.pipelineService.create(tenantId, dto); + } + + @Patch('stages/:id') + @ApiOperation({ summary: 'Update a pipeline stage' }) + @ApiResponse({ status: 200, description: 'Stage updated' }) + @ApiResponse({ status: 404, description: 'Stage not found' }) + async updateStage( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePipelineStageDto, + ) { + return this.pipelineService.update(tenantId, id, dto); + } + + @Delete('stages/:id') + @ApiOperation({ summary: 'Delete a pipeline stage' }) + @ApiResponse({ status: 200, description: 'Stage deleted' }) + @ApiResponse({ status: 400, description: 'Stage has opportunities' }) + @ApiResponse({ status: 404, description: 'Stage not found' }) + async deleteStage( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.pipelineService.remove(tenantId, id); + return { message: 'Stage deleted successfully' }; + } + + @Post('reorder') + @ApiOperation({ summary: 'Reorder pipeline stages' }) + @ApiResponse({ status: 200, description: 'Stages reordered' }) + async reorderStages( + @CurrentTenant() tenantId: string, + @Body() dto: ReorderStagesDto, + ) { + return this.pipelineService.reorderStages(tenantId, dto.stage_ids); + } + + @Post('initialize') + @ApiOperation({ summary: 'Initialize default pipeline stages for tenant' }) + @ApiResponse({ status: 201, description: 'Default stages created' }) + async initializeDefaultStages(@CurrentTenant() tenantId: string) { + return this.pipelineService.initializeDefaultStages(tenantId); + } +} diff --git a/apps/backend/src/modules/sales/dto/convert-lead.dto.ts b/apps/backend/src/modules/sales/dto/convert-lead.dto.ts new file mode 100644 index 00000000..32e2fe44 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/convert-lead.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsNumber, IsDateString, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class ConvertLeadDto { + @ApiPropertyOptional({ description: 'Name for the new opportunity' }) + @IsOptional() + @IsString() + opportunity_name?: string; + + @ApiPropertyOptional({ description: 'Expected deal amount' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ description: 'Currency code (e.g., USD, EUR)' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Expected close date' }) + @IsOptional() + @IsDateString() + expected_close_date?: Date; +} diff --git a/apps/backend/src/modules/sales/dto/create-activity.dto.ts b/apps/backend/src/modules/sales/dto/create-activity.dto.ts new file mode 100644 index 00000000..7d4d8be5 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/create-activity.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEnum, IsDateString, IsInt, IsArray, IsObject, Min, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActivityType, ActivityStatus } from '../entities/activity.entity'; + +export class CreateActivityDto { + @ApiProperty({ enum: ActivityType, description: 'Activity type' }) + @IsEnum(ActivityType) + type: ActivityType; + + @ApiProperty({ description: 'Activity subject' }) + @IsString() + subject: string; + + @ApiPropertyOptional({ description: 'Activity description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Related lead ID' }) + @IsOptional() + @IsString() + lead_id?: string; + + @ApiPropertyOptional({ description: 'Related opportunity ID' }) + @IsOptional() + @IsString() + opportunity_id?: string; + + @ApiPropertyOptional({ description: 'Due date' }) + @IsOptional() + @IsDateString() + due_date?: Date; + + @ApiPropertyOptional({ description: 'Due time' }) + @IsOptional() + @IsString() + due_time?: string; + + @ApiPropertyOptional({ description: 'Duration in minutes' }) + @IsOptional() + @IsInt() + @Min(0) + duration_minutes?: number; + + @ApiPropertyOptional({ description: 'Assigned user ID' }) + @IsOptional() + @IsString() + assigned_to?: string; + + @ApiPropertyOptional({ description: 'Call direction (for calls)', enum: ['inbound', 'outbound'] }) + @IsOptional() + @IsString() + call_direction?: 'inbound' | 'outbound'; + + @ApiPropertyOptional({ description: 'Location (for meetings)' }) + @IsOptional() + @IsString() + location?: string; + + @ApiPropertyOptional({ description: 'Meeting URL' }) + @IsOptional() + @IsString() + meeting_url?: string; + + @ApiPropertyOptional({ description: 'Meeting attendees', type: 'array' }) + @IsOptional() + @IsArray() + attendees?: Array<{ + name: string; + email: string; + status?: 'accepted' | 'declined' | 'tentative' | 'pending'; + }>; + + @ApiPropertyOptional({ description: 'Reminder date/time' }) + @IsOptional() + @IsDateString() + reminder_at?: Date; + + @ApiPropertyOptional({ description: 'Custom fields' }) + @IsOptional() + @IsObject() + custom_fields?: Record; +} diff --git a/apps/backend/src/modules/sales/dto/create-lead.dto.ts b/apps/backend/src/modules/sales/dto/create-lead.dto.ts new file mode 100644 index 00000000..c5bd7420 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/create-lead.dto.ts @@ -0,0 +1,100 @@ +import { IsString, IsEmail, IsOptional, IsEnum, IsInt, Min, Max, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LeadStatus, LeadSource } from '../entities/lead.entity'; + +export class CreateLeadDto { + @ApiProperty({ description: 'First name of the lead' }) + @IsString() + first_name: string; + + @ApiProperty({ description: 'Last name of the lead' }) + @IsString() + last_name: string; + + @ApiPropertyOptional({ description: 'Email address' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ description: 'Phone number' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ description: 'Company name' }) + @IsOptional() + @IsString() + company?: string; + + @ApiPropertyOptional({ description: 'Job title' }) + @IsOptional() + @IsString() + job_title?: string; + + @ApiPropertyOptional({ description: 'Website URL' }) + @IsOptional() + @IsString() + website?: string; + + @ApiPropertyOptional({ enum: LeadSource, default: LeadSource.OTHER }) + @IsOptional() + @IsEnum(LeadSource) + source?: LeadSource; + + @ApiPropertyOptional({ enum: LeadStatus, default: LeadStatus.NEW }) + @IsOptional() + @IsEnum(LeadStatus) + status?: LeadStatus; + + @ApiPropertyOptional({ description: 'Lead score (0-100)', minimum: 0, maximum: 100 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + score?: number; + + @ApiPropertyOptional({ description: 'Assigned user ID' }) + @IsOptional() + @IsString() + assigned_to?: string; + + @ApiPropertyOptional({ description: 'Notes about the lead' }) + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional({ description: 'Address line 1' }) + @IsOptional() + @IsString() + address_line1?: string; + + @ApiPropertyOptional({ description: 'Address line 2' }) + @IsOptional() + @IsString() + address_line2?: string; + + @ApiPropertyOptional({ description: 'City' }) + @IsOptional() + @IsString() + city?: string; + + @ApiPropertyOptional({ description: 'State' }) + @IsOptional() + @IsString() + state?: string; + + @ApiPropertyOptional({ description: 'Postal code' }) + @IsOptional() + @IsString() + postal_code?: string; + + @ApiPropertyOptional({ description: 'Country' }) + @IsOptional() + @IsString() + country?: string; + + @ApiPropertyOptional({ description: 'Custom fields' }) + @IsOptional() + @IsObject() + custom_fields?: Record; +} diff --git a/apps/backend/src/modules/sales/dto/create-opportunity.dto.ts b/apps/backend/src/modules/sales/dto/create-opportunity.dto.ts new file mode 100644 index 00000000..6e08f53d --- /dev/null +++ b/apps/backend/src/modules/sales/dto/create-opportunity.dto.ts @@ -0,0 +1,87 @@ +import { IsString, IsOptional, IsEnum, IsNumber, IsInt, Min, Max, IsDateString, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { OpportunityStage } from '../entities/opportunity.entity'; + +export class CreateOpportunityDto { + @ApiProperty({ description: 'Opportunity name' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Lead ID (if converted from lead)' }) + @IsOptional() + @IsString() + lead_id?: string; + + @ApiPropertyOptional({ enum: OpportunityStage, default: OpportunityStage.PROSPECTING }) + @IsOptional() + @IsEnum(OpportunityStage) + stage?: OpportunityStage; + + @ApiPropertyOptional({ description: 'Custom pipeline stage ID' }) + @IsOptional() + @IsString() + stage_id?: string; + + @ApiPropertyOptional({ description: 'Deal amount' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ description: 'Currency code', default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Win probability (0-100)', minimum: 0, maximum: 100 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @ApiPropertyOptional({ description: 'Expected close date' }) + @IsOptional() + @IsDateString() + expected_close_date?: Date; + + @ApiPropertyOptional({ description: 'Assigned user ID' }) + @IsOptional() + @IsString() + assigned_to?: string; + + @ApiPropertyOptional({ description: 'Contact name' }) + @IsOptional() + @IsString() + contact_name?: string; + + @ApiPropertyOptional({ description: 'Contact email' }) + @IsOptional() + @IsString() + contact_email?: string; + + @ApiPropertyOptional({ description: 'Contact phone' }) + @IsOptional() + @IsString() + contact_phone?: string; + + @ApiPropertyOptional({ description: 'Company name' }) + @IsOptional() + @IsString() + company_name?: string; + + @ApiPropertyOptional({ description: 'Notes' }) + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional({ description: 'Custom fields' }) + @IsOptional() + @IsObject() + custom_fields?: Record; +} diff --git a/apps/backend/src/modules/sales/dto/index.ts b/apps/backend/src/modules/sales/dto/index.ts new file mode 100644 index 00000000..5363d7c6 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/index.ts @@ -0,0 +1,8 @@ +export * from './create-lead.dto'; +export * from './update-lead.dto'; +export * from './convert-lead.dto'; +export * from './create-opportunity.dto'; +export * from './update-opportunity.dto'; +export * from './move-opportunity.dto'; +export * from './create-activity.dto'; +export * from './update-activity.dto'; diff --git a/apps/backend/src/modules/sales/dto/move-opportunity.dto.ts b/apps/backend/src/modules/sales/dto/move-opportunity.dto.ts new file mode 100644 index 00000000..f2100995 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/move-opportunity.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { OpportunityStage } from '../entities/opportunity.entity'; + +export class MoveOpportunityDto { + @ApiProperty({ enum: OpportunityStage, description: 'New stage' }) + @IsEnum(OpportunityStage) + stage: OpportunityStage; + + @ApiPropertyOptional({ description: 'Custom pipeline stage ID' }) + @IsOptional() + @IsString() + stage_id?: string; + + @ApiPropertyOptional({ description: 'Notes about the stage change' }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/backend/src/modules/sales/dto/update-activity.dto.ts b/apps/backend/src/modules/sales/dto/update-activity.dto.ts new file mode 100644 index 00000000..a4b99385 --- /dev/null +++ b/apps/backend/src/modules/sales/dto/update-activity.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateActivityDto } from './create-activity.dto'; + +export class UpdateActivityDto extends PartialType(CreateActivityDto) {} diff --git a/apps/backend/src/modules/sales/dto/update-lead.dto.ts b/apps/backend/src/modules/sales/dto/update-lead.dto.ts new file mode 100644 index 00000000..c8740ddb --- /dev/null +++ b/apps/backend/src/modules/sales/dto/update-lead.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateLeadDto } from './create-lead.dto'; + +export class UpdateLeadDto extends PartialType(CreateLeadDto) {} diff --git a/apps/backend/src/modules/sales/dto/update-opportunity.dto.ts b/apps/backend/src/modules/sales/dto/update-opportunity.dto.ts new file mode 100644 index 00000000..3605054f --- /dev/null +++ b/apps/backend/src/modules/sales/dto/update-opportunity.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateOpportunityDto } from './create-opportunity.dto'; + +export class UpdateOpportunityDto extends PartialType(CreateOpportunityDto) {} diff --git a/apps/backend/src/modules/sales/entities/activity.entity.ts b/apps/backend/src/modules/sales/entities/activity.entity.ts new file mode 100644 index 00000000..2205d7ad --- /dev/null +++ b/apps/backend/src/modules/sales/entities/activity.entity.ts @@ -0,0 +1,145 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { Lead } from './lead.entity'; +import { Opportunity } 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({ name: 'activities', schema: 'sales' }) +export class Activity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ + type: 'enum', + enum: ActivityType, + }) + type: ActivityType; + + @Column({ + type: 'enum', + enum: ActivityStatus, + default: ActivityStatus.PENDING, + }) + status: ActivityStatus; + + @Column({ type: 'varchar', length: 255 }) + subject: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'uuid', nullable: true }) + lead_id: string | null; + + @ManyToOne(() => Lead, { nullable: true }) + @JoinColumn({ name: 'lead_id' }) + lead?: Lead; + + @Column({ type: 'uuid', nullable: true }) + opportunity_id: string | null; + + @ManyToOne(() => Opportunity, { nullable: true }) + @JoinColumn({ name: 'opportunity_id' }) + opportunity?: Opportunity; + + @Column({ type: 'timestamp with time zone', nullable: true }) + due_date: Date | null; + + @Column({ type: 'time', nullable: true }) + due_time: string | null; + + @Column({ type: 'int', nullable: true }) + duration_minutes: number | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + completed_at: Date | null; + + @Column({ type: 'text', nullable: true }) + outcome: string | null; + + @Column({ type: 'uuid', nullable: true }) + assigned_to: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assigned_to' }) + assignedUser?: User; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdByUser?: User; + + // Call-specific fields + @Column({ type: 'varchar', length: 10, nullable: true }) + call_direction: 'inbound' | 'outbound' | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + call_recording_url: string | null; + + // Meeting-specific fields + @Column({ type: 'varchar', length: 255, nullable: true }) + location: string | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + meeting_url: string | null; + + @Column({ type: 'jsonb', default: [] }) + attendees: Array<{ + name: string; + email: string; + status?: 'accepted' | 'declined' | 'tentative' | 'pending'; + }>; + + // Reminder + @Column({ type: 'timestamp with time zone', nullable: true }) + reminder_at: Date | null; + + @Column({ type: 'boolean', default: false }) + reminder_sent: boolean; + + @Column({ type: 'jsonb', default: {} }) + custom_fields: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deleted_at: Date | null; + + // Check if activity is overdue + get isOverdue(): boolean { + if (!this.due_date || this.status !== ActivityStatus.PENDING) { + return false; + } + return new Date(this.due_date) < new Date(); + } +} diff --git a/apps/backend/src/modules/sales/entities/index.ts b/apps/backend/src/modules/sales/entities/index.ts new file mode 100644 index 00000000..7b09fd1e --- /dev/null +++ b/apps/backend/src/modules/sales/entities/index.ts @@ -0,0 +1,4 @@ +export * from './lead.entity'; +export * from './opportunity.entity'; +export * from './pipeline-stage.entity'; +export * from './activity.entity'; diff --git a/apps/backend/src/modules/sales/entities/lead.entity.ts b/apps/backend/src/modules/sales/entities/lead.entity.ts new file mode 100644 index 00000000..d567bf80 --- /dev/null +++ b/apps/backend/src/modules/sales/entities/lead.entity.ts @@ -0,0 +1,130 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; + +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({ name: 'leads', schema: 'sales' }) +export class Lead { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar', length: 100 }) + first_name: string; + + @Column({ type: 'varchar', length: 100 }) + last_name: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + phone: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + company: string | null; + + @Column({ type: 'varchar', length: 150, nullable: true }) + job_title: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + website: string | null; + + @Column({ + type: 'enum', + enum: LeadSource, + default: LeadSource.OTHER, + }) + source: LeadSource; + + @Column({ + type: 'enum', + enum: LeadStatus, + default: LeadStatus.NEW, + }) + status: LeadStatus; + + @Column({ type: 'int', default: 0 }) + score: number; + + @Column({ type: 'uuid', nullable: true }) + assigned_to: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assigned_to' }) + assignedUser?: User; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + converted_at: Date | null; + + @Column({ type: 'uuid', nullable: true }) + converted_to_opportunity_id: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + address_line1: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + address_line2: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + postal_code: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + country: string | null; + + @Column({ type: 'jsonb', default: {} }) + custom_fields: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deleted_at: Date | null; + + // Virtual property for full name + get fullName(): string { + return `${this.first_name} ${this.last_name}`; + } +} diff --git a/apps/backend/src/modules/sales/entities/opportunity.entity.ts b/apps/backend/src/modules/sales/entities/opportunity.entity.ts new file mode 100644 index 00000000..bdc6d7f4 --- /dev/null +++ b/apps/backend/src/modules/sales/entities/opportunity.entity.ts @@ -0,0 +1,128 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { Lead } from './lead.entity'; +import { PipelineStage } from './pipeline-stage.entity'; + +export enum OpportunityStage { + PROSPECTING = 'prospecting', + QUALIFICATION = 'qualification', + PROPOSAL = 'proposal', + NEGOTIATION = 'negotiation', + CLOSED_WON = 'closed_won', + CLOSED_LOST = 'closed_lost', +} + +@Entity({ name: 'opportunities', schema: 'sales' }) +export class Opportunity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'uuid', nullable: true }) + lead_id: string | null; + + @ManyToOne(() => Lead, { nullable: true }) + @JoinColumn({ name: 'lead_id' }) + lead?: Lead; + + @Column({ + type: 'enum', + enum: OpportunityStage, + default: OpportunityStage.PROSPECTING, + }) + stage: OpportunityStage; + + @Column({ type: 'uuid', nullable: true }) + stage_id: string | null; + + @ManyToOne(() => PipelineStage, { nullable: true }) + @JoinColumn({ name: 'stage_id' }) + pipelineStage?: PipelineStage; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ type: 'int', default: 0 }) + probability: number; + + @Column({ type: 'date', nullable: true }) + expected_close_date: Date | null; + + @Column({ type: 'date', nullable: true }) + actual_close_date: Date | null; + + @Column({ type: 'uuid', nullable: true }) + assigned_to: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assigned_to' }) + assignedUser?: User; + + @Column({ type: 'timestamp with time zone', nullable: true }) + won_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + lost_at: Date | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + lost_reason: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + contact_name: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + contact_email: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + contact_phone: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + company_name: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'jsonb', default: {} }) + custom_fields: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deleted_at: Date | null; + + // Computed property for weighted amount + get weightedAmount(): number { + return Number(this.amount) * (this.probability / 100); + } + + // Check if opportunity is open + get isOpen(): boolean { + return this.won_at === null && this.lost_at === null; + } +} diff --git a/apps/backend/src/modules/sales/entities/pipeline-stage.entity.ts b/apps/backend/src/modules/sales/entities/pipeline-stage.entity.ts new file mode 100644 index 00000000..2a833f37 --- /dev/null +++ b/apps/backend/src/modules/sales/entities/pipeline-stage.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'pipeline_stages', schema: 'sales' }) +export class PipelineStage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'int', default: 0 }) + position: number; + + @Column({ type: 'varchar', length: 7, default: '#3B82F6' }) + color: string; + + @Column({ type: 'boolean', default: false }) + is_won: boolean; + + @Column({ type: 'boolean', default: false }) + is_lost: boolean; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/apps/backend/src/modules/sales/sales.module.ts b/apps/backend/src/modules/sales/sales.module.ts new file mode 100644 index 00000000..7fccfc74 --- /dev/null +++ b/apps/backend/src/modules/sales/sales.module.ts @@ -0,0 +1,55 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Entities +import { Lead } from './entities/lead.entity'; +import { Opportunity } from './entities/opportunity.entity'; +import { PipelineStage } from './entities/pipeline-stage.entity'; +import { Activity } from './entities/activity.entity'; + +// Services +import { LeadsService } from './services/leads.service'; +import { OpportunitiesService } from './services/opportunities.service'; +import { PipelineService } from './services/pipeline.service'; +import { ActivitiesService } from './services/activities.service'; +import { SalesDashboardService } from './services/sales-dashboard.service'; + +// Controllers +import { LeadsController } from './controllers/leads.controller'; +import { OpportunitiesController } from './controllers/opportunities.controller'; +import { PipelineController } from './controllers/pipeline.controller'; +import { ActivitiesController } from './controllers/activities.controller'; +import { DashboardController } from './controllers/dashboard.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Lead, + Opportunity, + PipelineStage, + Activity, + ]), + ], + controllers: [ + LeadsController, + OpportunitiesController, + PipelineController, + ActivitiesController, + DashboardController, + ], + providers: [ + LeadsService, + OpportunitiesService, + PipelineService, + ActivitiesService, + SalesDashboardService, + ], + exports: [ + LeadsService, + OpportunitiesService, + PipelineService, + ActivitiesService, + SalesDashboardService, + ], +}) +export class SalesModule {} diff --git a/apps/backend/src/modules/sales/services/activities.service.ts b/apps/backend/src/modules/sales/services/activities.service.ts new file mode 100644 index 00000000..18aa7a2b --- /dev/null +++ b/apps/backend/src/modules/sales/services/activities.service.ts @@ -0,0 +1,275 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Not } from 'typeorm'; +import { Activity, ActivityType, ActivityStatus } from '../entities/activity.entity'; +import { CreateActivityDto, UpdateActivityDto } from '../dto'; + +export interface ActivityFilters { + type?: ActivityType; + status?: ActivityStatus; + lead_id?: string; + opportunity_id?: string; + assigned_to?: string; + due_from?: Date; + due_to?: Date; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class ActivitiesService { + constructor( + @InjectRepository(Activity) + private readonly activityRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: ActivityFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'due_date', sortOrder = 'ASC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.activityRepository.createQueryBuilder('activity') + .leftJoinAndSelect('activity.lead', 'lead') + .leftJoinAndSelect('activity.opportunity', 'opportunity') + .leftJoinAndSelect('activity.assignedUser', 'assignedUser') + .where('activity.tenant_id = :tenantId', { tenantId }) + .andWhere('activity.deleted_at IS NULL'); + + if (filters.type) { + queryBuilder.andWhere('activity.type = :type', { type: filters.type }); + } + + if (filters.status) { + queryBuilder.andWhere('activity.status = :status', { status: filters.status }); + } + + if (filters.lead_id) { + queryBuilder.andWhere('activity.lead_id = :leadId', { leadId: filters.lead_id }); + } + + if (filters.opportunity_id) { + queryBuilder.andWhere('activity.opportunity_id = :opportunityId', { opportunityId: filters.opportunity_id }); + } + + if (filters.assigned_to) { + queryBuilder.andWhere('activity.assigned_to = :assignedTo', { assignedTo: filters.assigned_to }); + } + + if (filters.due_from) { + queryBuilder.andWhere('activity.due_date >= :dueFrom', { dueFrom: filters.due_from }); + } + + if (filters.due_to) { + queryBuilder.andWhere('activity.due_date <= :dueTo', { dueTo: filters.due_to }); + } + + const [data, total] = await queryBuilder + .orderBy(`activity.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const activity = await this.activityRepository.findOne({ + where: { id, tenant_id: tenantId, deleted_at: undefined }, + relations: ['lead', 'opportunity', 'assignedUser', 'createdByUser'], + }); + + if (!activity) { + throw new NotFoundException(`Activity with ID ${id} not found`); + } + + return activity; + } + + async create(tenantId: string, dto: CreateActivityDto, createdBy?: string): Promise { + if (!dto.lead_id && !dto.opportunity_id) { + throw new BadRequestException('Activity must be linked to a lead or opportunity'); + } + + const activity = this.activityRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + }); + + return this.activityRepository.save(activity); + } + + async update(tenantId: string, id: string, dto: UpdateActivityDto): Promise { + const activity = await this.findOne(tenantId, id); + + if (activity.status === ActivityStatus.COMPLETED) { + throw new BadRequestException('Cannot update a completed activity'); + } + + Object.assign(activity, dto); + return this.activityRepository.save(activity); + } + + async remove(tenantId: string, id: string): Promise { + const activity = await this.findOne(tenantId, id); + activity.deleted_at = new Date(); + await this.activityRepository.save(activity); + } + + async findByLead(tenantId: string, leadId: string): Promise { + return this.activityRepository.find({ + where: { tenant_id: tenantId, lead_id: leadId, deleted_at: undefined }, + relations: ['assignedUser'], + order: { created_at: 'DESC' }, + }); + } + + async findByOpportunity(tenantId: string, opportunityId: string): Promise { + return this.activityRepository.find({ + where: { tenant_id: tenantId, opportunity_id: opportunityId, deleted_at: undefined }, + relations: ['assignedUser'], + order: { created_at: 'DESC' }, + }); + } + + async getUpcoming( + tenantId: string, + userId?: string, + days: number = 7, + ): Promise { + const now = new Date(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + const queryBuilder = this.activityRepository.createQueryBuilder('activity') + .leftJoinAndSelect('activity.lead', 'lead') + .leftJoinAndSelect('activity.opportunity', 'opportunity') + .where('activity.tenant_id = :tenantId', { tenantId }) + .andWhere('activity.deleted_at IS NULL') + .andWhere('activity.status = :status', { status: ActivityStatus.PENDING }) + .andWhere('activity.due_date IS NOT NULL') + .andWhere('activity.due_date >= :now', { now }) + .andWhere('activity.due_date <= :futureDate', { futureDate }); + + if (userId) { + queryBuilder.andWhere('activity.assigned_to = :userId', { userId }); + } + + return queryBuilder + .orderBy('activity.due_date', 'ASC') + .getMany(); + } + + async getOverdue(tenantId: string, userId?: string): Promise { + const now = new Date(); + + const queryBuilder = this.activityRepository.createQueryBuilder('activity') + .leftJoinAndSelect('activity.lead', 'lead') + .leftJoinAndSelect('activity.opportunity', 'opportunity') + .where('activity.tenant_id = :tenantId', { tenantId }) + .andWhere('activity.deleted_at IS NULL') + .andWhere('activity.status = :status', { status: ActivityStatus.PENDING }) + .andWhere('activity.due_date IS NOT NULL') + .andWhere('activity.due_date < :now', { now }); + + if (userId) { + queryBuilder.andWhere('activity.assigned_to = :userId', { userId }); + } + + return queryBuilder + .orderBy('activity.due_date', 'ASC') + .getMany(); + } + + async markAsCompleted( + tenantId: string, + id: string, + outcome?: string, + ): Promise { + const activity = await this.findOne(tenantId, id); + + if (activity.status === ActivityStatus.COMPLETED) { + throw new BadRequestException('Activity is already completed'); + } + + activity.status = ActivityStatus.COMPLETED; + activity.completed_at = new Date(); + if (outcome) { + activity.outcome = outcome; + } + + return this.activityRepository.save(activity); + } + + async markAsCancelled(tenantId: string, id: string): Promise { + const activity = await this.findOne(tenantId, id); + + if (activity.status !== ActivityStatus.PENDING) { + throw new BadRequestException('Only pending activities can be cancelled'); + } + + activity.status = ActivityStatus.CANCELLED; + return this.activityRepository.save(activity); + } + + async getStats(tenantId: string, userId?: string): Promise<{ + total: number; + pending: number; + completed: number; + overdue: number; + byType: Record; + }> { + const queryBuilder = this.activityRepository.createQueryBuilder('activity') + .where('activity.tenant_id = :tenantId', { tenantId }) + .andWhere('activity.deleted_at IS NULL'); + + if (userId) { + queryBuilder.andWhere('activity.assigned_to = :userId', { userId }); + } + + const activities = await queryBuilder.getMany(); + + const now = new Date(); + const pending = activities.filter((a) => a.status === ActivityStatus.PENDING); + const completed = activities.filter((a) => a.status === ActivityStatus.COMPLETED); + const overdue = pending.filter((a) => a.due_date && new Date(a.due_date) < now); + + const byType = activities.reduce( + (acc, activity) => { + acc[activity.type] = (acc[activity.type] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return { + total: activities.length, + pending: pending.length, + completed: completed.length, + overdue: overdue.length, + byType, + }; + } +} diff --git a/apps/backend/src/modules/sales/services/index.ts b/apps/backend/src/modules/sales/services/index.ts new file mode 100644 index 00000000..efc2348a --- /dev/null +++ b/apps/backend/src/modules/sales/services/index.ts @@ -0,0 +1,5 @@ +export * from './leads.service'; +export * from './opportunities.service'; +export * from './pipeline.service'; +export * from './activities.service'; +export * from './sales-dashboard.service'; diff --git a/apps/backend/src/modules/sales/services/leads.service.ts b/apps/backend/src/modules/sales/services/leads.service.ts new file mode 100644 index 00000000..e9abb129 --- /dev/null +++ b/apps/backend/src/modules/sales/services/leads.service.ts @@ -0,0 +1,235 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Lead, LeadStatus, LeadSource } from '../entities/lead.entity'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; +import { CreateLeadDto, UpdateLeadDto, ConvertLeadDto } from '../dto'; + +export interface LeadFilters { + status?: LeadStatus; + source?: LeadSource; + assigned_to?: string; + search?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class LeadsService { + constructor( + @InjectRepository(Lead) + private readonly leadRepository: Repository, + @InjectRepository(Opportunity) + private readonly opportunityRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: LeadFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const where: FindOptionsWhere = { + tenant_id: tenantId, + deleted_at: undefined, + }; + + if (filters.status) { + where.status = filters.status; + } + + if (filters.source) { + where.source = filters.source; + } + + if (filters.assigned_to) { + where.assigned_to = filters.assigned_to; + } + + const queryBuilder = this.leadRepository.createQueryBuilder('lead') + .where('lead.tenant_id = :tenantId', { tenantId }) + .andWhere('lead.deleted_at IS NULL'); + + if (filters.status) { + queryBuilder.andWhere('lead.status = :status', { status: filters.status }); + } + + if (filters.source) { + queryBuilder.andWhere('lead.source = :source', { source: filters.source }); + } + + if (filters.assigned_to) { + queryBuilder.andWhere('lead.assigned_to = :assignedTo', { assignedTo: filters.assigned_to }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(lead.first_name ILIKE :search OR lead.last_name ILIKE :search OR lead.email ILIKE :search OR lead.company ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy(`lead.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const lead = await this.leadRepository.findOne({ + where: { id, tenant_id: tenantId, deleted_at: undefined }, + relations: ['assignedUser'], + }); + + if (!lead) { + throw new NotFoundException(`Lead with ID ${id} not found`); + } + + return lead; + } + + async create(tenantId: string, dto: CreateLeadDto, createdBy?: string): Promise { + const lead = this.leadRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + }); + + return this.leadRepository.save(lead); + } + + async update(tenantId: string, id: string, dto: UpdateLeadDto): Promise { + const lead = await this.findOne(tenantId, id); + + if (lead.status === LeadStatus.CONVERTED) { + throw new BadRequestException('Cannot update a converted lead'); + } + + Object.assign(lead, dto); + return this.leadRepository.save(lead); + } + + async remove(tenantId: string, id: string): Promise { + const lead = await this.findOne(tenantId, id); + lead.deleted_at = new Date(); + await this.leadRepository.save(lead); + } + + async convertToOpportunity( + tenantId: string, + id: string, + dto: ConvertLeadDto, + ): Promise { + const lead = await this.findOne(tenantId, id); + + if (lead.status === LeadStatus.CONVERTED) { + throw new BadRequestException('Lead is already converted'); + } + + // Create opportunity from lead + const opportunity = this.opportunityRepository.create({ + tenant_id: tenantId, + name: dto.opportunity_name || `${lead.company || lead.fullName} - Opportunity`, + lead_id: lead.id, + stage: OpportunityStage.PROSPECTING, + amount: dto.amount || 0, + currency: dto.currency || 'USD', + expected_close_date: dto.expected_close_date, + assigned_to: lead.assigned_to, + contact_name: lead.fullName, + contact_email: lead.email, + contact_phone: lead.phone, + company_name: lead.company, + notes: lead.notes, + created_by: lead.created_by, + }); + + const savedOpportunity = await this.opportunityRepository.save(opportunity); + + // Update lead as converted + lead.status = LeadStatus.CONVERTED; + lead.converted_at = new Date(); + lead.converted_to_opportunity_id = savedOpportunity.id; + await this.leadRepository.save(lead); + + return savedOpportunity; + } + + async assignTo(tenantId: string, id: string, userId: string): Promise { + const lead = await this.findOne(tenantId, id); + lead.assigned_to = userId; + return this.leadRepository.save(lead); + } + + async updateScore(tenantId: string, id: string, score: number): Promise { + if (score < 0 || score > 100) { + throw new BadRequestException('Score must be between 0 and 100'); + } + + const lead = await this.findOne(tenantId, id); + lead.score = score; + return this.leadRepository.save(lead); + } + + async getStats(tenantId: string): Promise<{ + total: number; + byStatus: Record; + bySource: Record; + avgScore: number; + }> { + const leads = await this.leadRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const byStatus = leads.reduce( + (acc, lead) => { + acc[lead.status] = (acc[lead.status] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const bySource = leads.reduce( + (acc, lead) => { + acc[lead.source] = (acc[lead.source] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const avgScore = leads.length > 0 + ? leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length + : 0; + + return { + total: leads.length, + byStatus, + bySource, + avgScore: Math.round(avgScore), + }; + } +} diff --git a/apps/backend/src/modules/sales/services/opportunities.service.ts b/apps/backend/src/modules/sales/services/opportunities.service.ts new file mode 100644 index 00000000..bf25a1ca --- /dev/null +++ b/apps/backend/src/modules/sales/services/opportunities.service.ts @@ -0,0 +1,342 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; +import { PipelineStage } from '../entities/pipeline-stage.entity'; +import { CreateOpportunityDto, UpdateOpportunityDto, MoveOpportunityDto } from '../dto'; + +export interface OpportunityFilters { + stage?: OpportunityStage; + stage_id?: string; + assigned_to?: string; + min_amount?: number; + max_amount?: number; + expected_close_from?: Date; + expected_close_to?: Date; + search?: string; + is_open?: boolean; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PipelineView { + stage: OpportunityStage; + stageName: string; + opportunities: Opportunity[]; + count: number; + totalAmount: number; +} + +@Injectable() +export class OpportunitiesService { + constructor( + @InjectRepository(Opportunity) + private readonly opportunityRepository: Repository, + @InjectRepository(PipelineStage) + private readonly pipelineStageRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: OpportunityFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.opportunityRepository.createQueryBuilder('opportunity') + .leftJoinAndSelect('opportunity.lead', 'lead') + .leftJoinAndSelect('opportunity.assignedUser', 'assignedUser') + .where('opportunity.tenant_id = :tenantId', { tenantId }) + .andWhere('opportunity.deleted_at IS NULL'); + + if (filters.stage) { + queryBuilder.andWhere('opportunity.stage = :stage', { stage: filters.stage }); + } + + if (filters.stage_id) { + queryBuilder.andWhere('opportunity.stage_id = :stageId', { stageId: filters.stage_id }); + } + + if (filters.assigned_to) { + queryBuilder.andWhere('opportunity.assigned_to = :assignedTo', { assignedTo: filters.assigned_to }); + } + + if (filters.min_amount !== undefined) { + queryBuilder.andWhere('opportunity.amount >= :minAmount', { minAmount: filters.min_amount }); + } + + if (filters.max_amount !== undefined) { + queryBuilder.andWhere('opportunity.amount <= :maxAmount', { maxAmount: filters.max_amount }); + } + + if (filters.expected_close_from) { + queryBuilder.andWhere('opportunity.expected_close_date >= :closeFrom', { closeFrom: filters.expected_close_from }); + } + + if (filters.expected_close_to) { + queryBuilder.andWhere('opportunity.expected_close_date <= :closeTo', { closeTo: filters.expected_close_to }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(opportunity.name ILIKE :search OR opportunity.company_name ILIKE :search OR opportunity.contact_name ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + if (filters.is_open === true) { + queryBuilder.andWhere('opportunity.won_at IS NULL AND opportunity.lost_at IS NULL'); + } else if (filters.is_open === false) { + queryBuilder.andWhere('(opportunity.won_at IS NOT NULL OR opportunity.lost_at IS NOT NULL)'); + } + + const [data, total] = await queryBuilder + .orderBy(`opportunity.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const opportunity = await this.opportunityRepository.findOne({ + where: { id, tenant_id: tenantId, deleted_at: undefined }, + relations: ['lead', 'assignedUser', 'pipelineStage'], + }); + + if (!opportunity) { + throw new NotFoundException(`Opportunity with ID ${id} not found`); + } + + return opportunity; + } + + async create(tenantId: string, dto: CreateOpportunityDto, createdBy?: string): Promise { + const opportunity = this.opportunityRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + }); + + return this.opportunityRepository.save(opportunity); + } + + async update(tenantId: string, id: string, dto: UpdateOpportunityDto): Promise { + const opportunity = await this.findOne(tenantId, id); + + if (opportunity.won_at || opportunity.lost_at) { + throw new BadRequestException('Cannot update a closed opportunity'); + } + + Object.assign(opportunity, dto); + return this.opportunityRepository.save(opportunity); + } + + async remove(tenantId: string, id: string): Promise { + const opportunity = await this.findOne(tenantId, id); + opportunity.deleted_at = new Date(); + await this.opportunityRepository.save(opportunity); + } + + async moveToStage(tenantId: string, id: string, dto: MoveOpportunityDto): Promise { + const opportunity = await this.findOne(tenantId, id); + + if (opportunity.won_at || opportunity.lost_at) { + throw new BadRequestException('Cannot move a closed opportunity'); + } + + opportunity.stage = dto.stage; + if (dto.stage_id) { + opportunity.stage_id = dto.stage_id; + } + + if (dto.notes) { + opportunity.notes = opportunity.notes + ? `${opportunity.notes}\n\n[Stage Change] ${dto.notes}` + : `[Stage Change] ${dto.notes}`; + } + + return this.opportunityRepository.save(opportunity); + } + + async markAsWon(tenantId: string, id: string, notes?: string): Promise { + const opportunity = await this.findOne(tenantId, id); + + if (opportunity.won_at || opportunity.lost_at) { + throw new BadRequestException('Opportunity is already closed'); + } + + opportunity.stage = OpportunityStage.CLOSED_WON; + opportunity.won_at = new Date(); + opportunity.actual_close_date = new Date(); + opportunity.probability = 100; + + if (notes) { + opportunity.notes = opportunity.notes + ? `${opportunity.notes}\n\n[Won] ${notes}` + : `[Won] ${notes}`; + } + + return this.opportunityRepository.save(opportunity); + } + + async markAsLost(tenantId: string, id: string, reason?: string): Promise { + const opportunity = await this.findOne(tenantId, id); + + if (opportunity.won_at || opportunity.lost_at) { + throw new BadRequestException('Opportunity is already closed'); + } + + opportunity.stage = OpportunityStage.CLOSED_LOST; + opportunity.lost_at = new Date(); + opportunity.actual_close_date = new Date(); + opportunity.probability = 0; + opportunity.lost_reason = reason || null; + + return this.opportunityRepository.save(opportunity); + } + + async getByStage(tenantId: string): Promise { + const stages = Object.values(OpportunityStage); + const stageNames: Record = { + [OpportunityStage.PROSPECTING]: 'Prospecting', + [OpportunityStage.QUALIFICATION]: 'Qualification', + [OpportunityStage.PROPOSAL]: 'Proposal', + [OpportunityStage.NEGOTIATION]: 'Negotiation', + [OpportunityStage.CLOSED_WON]: 'Closed Won', + [OpportunityStage.CLOSED_LOST]: 'Closed Lost', + }; + + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + relations: ['assignedUser'], + order: { amount: 'DESC' }, + }); + + return stages.map((stage) => { + const stageOpportunities = opportunities.filter((o) => o.stage === stage); + return { + stage, + stageName: stageNames[stage], + opportunities: stageOpportunities, + count: stageOpportunities.length, + totalAmount: stageOpportunities.reduce((sum, o) => sum + Number(o.amount), 0), + }; + }); + } + + async getForecast( + tenantId: string, + startDate: Date, + endDate: Date, + ): Promise<{ + totalPipeline: number; + weightedPipeline: number; + expectedRevenue: number; + byMonth: Array<{ month: string; amount: number; weighted: number }>; + }> { + const opportunities = await this.opportunityRepository.find({ + where: { + tenant_id: tenantId, + deleted_at: undefined, + won_at: undefined, + lost_at: undefined, + }, + }); + + const relevantOpportunities = opportunities.filter((o) => { + if (!o.expected_close_date) return false; + const closeDate = new Date(o.expected_close_date); + return closeDate >= startDate && closeDate <= endDate; + }); + + const totalPipeline = relevantOpportunities.reduce((sum, o) => sum + Number(o.amount), 0); + const weightedPipeline = relevantOpportunities.reduce( + (sum, o) => sum + Number(o.amount) * (o.probability / 100), + 0, + ); + + // Group by month + const byMonth: Record = {}; + + relevantOpportunities.forEach((o) => { + if (!o.expected_close_date) return; + const date = new Date(o.expected_close_date); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + if (!byMonth[monthKey]) { + byMonth[monthKey] = { amount: 0, weighted: 0 }; + } + + byMonth[monthKey].amount += Number(o.amount); + byMonth[monthKey].weighted += Number(o.amount) * (o.probability / 100); + }); + + return { + totalPipeline, + weightedPipeline, + expectedRevenue: weightedPipeline, + byMonth: Object.entries(byMonth) + .map(([month, data]) => ({ month, ...data })) + .sort((a, b) => a.month.localeCompare(b.month)), + }; + } + + async getStats(tenantId: string): Promise<{ + total: number; + open: number; + won: number; + lost: number; + totalValue: number; + wonValue: number; + avgDealSize: number; + winRate: number; + }> { + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const open = opportunities.filter((o) => !o.won_at && !o.lost_at); + const won = opportunities.filter((o) => o.won_at); + const lost = opportunities.filter((o) => o.lost_at); + + const totalValue = opportunities.reduce((sum, o) => sum + Number(o.amount), 0); + const wonValue = won.reduce((sum, o) => sum + Number(o.amount), 0); + + const closedCount = won.length + lost.length; + const winRate = closedCount > 0 ? (won.length / closedCount) * 100 : 0; + const avgDealSize = won.length > 0 ? wonValue / won.length : 0; + + return { + total: opportunities.length, + open: open.length, + won: won.length, + lost: lost.length, + totalValue, + wonValue, + avgDealSize: Math.round(avgDealSize), + winRate: Math.round(winRate), + }; + } +} diff --git a/apps/backend/src/modules/sales/services/pipeline.service.ts b/apps/backend/src/modules/sales/services/pipeline.service.ts new file mode 100644 index 00000000..b62dcd04 --- /dev/null +++ b/apps/backend/src/modules/sales/services/pipeline.service.ts @@ -0,0 +1,177 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PipelineStage } from '../entities/pipeline-stage.entity'; +import { Opportunity } from '../entities/opportunity.entity'; + +export interface PipelineStageWithCount extends PipelineStage { + opportunityCount?: number; + totalAmount?: number; +} + +@Injectable() +export class PipelineService { + constructor( + @InjectRepository(PipelineStage) + private readonly pipelineStageRepository: Repository, + @InjectRepository(Opportunity) + private readonly opportunityRepository: Repository, + ) {} + + async getStages(tenantId: string): Promise { + const stages = await this.pipelineStageRepository.find({ + where: { tenant_id: tenantId, is_active: true }, + order: { position: 'ASC' }, + }); + + // Get opportunity counts per stage + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + return stages.map((stage) => { + const stageOpportunities = opportunities.filter((o) => o.stage_id === stage.id); + return { + ...stage, + opportunityCount: stageOpportunities.length, + totalAmount: stageOpportunities.reduce((sum, o) => sum + Number(o.amount), 0), + }; + }); + } + + async findOne(tenantId: string, id: string): Promise { + const stage = await this.pipelineStageRepository.findOne({ + where: { id, tenant_id: tenantId }, + }); + + if (!stage) { + throw new NotFoundException(`Pipeline stage with ID ${id} not found`); + } + + return stage; + } + + async create( + tenantId: string, + data: Partial, + ): Promise { + // Get max position + const maxPositionResult = await this.pipelineStageRepository + .createQueryBuilder('stage') + .select('MAX(stage.position)', 'maxPosition') + .where('stage.tenant_id = :tenantId', { tenantId }) + .getRawOne(); + + const nextPosition = (maxPositionResult?.maxPosition || 0) + 1; + + const stage = this.pipelineStageRepository.create({ + ...data, + tenant_id: tenantId, + position: data.position ?? nextPosition, + }); + + return this.pipelineStageRepository.save(stage); + } + + async update( + tenantId: string, + id: string, + data: Partial, + ): Promise { + const stage = await this.findOne(tenantId, id); + + // Prevent updating is_won/is_lost if stage has opportunities + if ((data.is_won !== undefined || data.is_lost !== undefined)) { + const hasOpportunities = await this.opportunityRepository.count({ + where: { tenant_id: tenantId, stage_id: id, deleted_at: undefined }, + }); + + if (hasOpportunities > 0) { + throw new BadRequestException( + 'Cannot change win/loss status of a stage with existing opportunities', + ); + } + } + + Object.assign(stage, data); + return this.pipelineStageRepository.save(stage); + } + + async remove(tenantId: string, id: string): Promise { + const stage = await this.findOne(tenantId, id); + + // Check if stage has opportunities + const hasOpportunities = await this.opportunityRepository.count({ + where: { tenant_id: tenantId, stage_id: id, deleted_at: undefined }, + }); + + if (hasOpportunities > 0) { + throw new BadRequestException( + 'Cannot delete a stage with existing opportunities. Move opportunities first.', + ); + } + + await this.pipelineStageRepository.remove(stage); + } + + async reorderStages( + tenantId: string, + stageIds: string[], + ): Promise { + const stages = await this.pipelineStageRepository.find({ + where: { tenant_id: tenantId }, + }); + + const stageMap = new Map(stages.map((s) => [s.id, s])); + + // Validate all IDs exist + for (const id of stageIds) { + if (!stageMap.has(id)) { + throw new BadRequestException(`Stage with ID ${id} not found`); + } + } + + // Update positions + const updatedStages: PipelineStage[] = []; + for (let i = 0; i < stageIds.length; i++) { + const stage = stageMap.get(stageIds[i])!; + stage.position = i + 1; + updatedStages.push(stage); + } + + await this.pipelineStageRepository.save(updatedStages); + + return this.getStages(tenantId); + } + + async initializeDefaultStages(tenantId: string): Promise { + // Check if stages already exist + const existingStages = await this.pipelineStageRepository.count({ + where: { tenant_id: tenantId }, + }); + + if (existingStages > 0) { + return this.getStages(tenantId); + } + + const defaultStages = [ + { name: 'Prospecting', position: 1, color: '#94A3B8', is_won: false, is_lost: false }, + { name: 'Qualification', position: 2, color: '#3B82F6', is_won: false, is_lost: false }, + { name: 'Proposal', position: 3, color: '#8B5CF6', is_won: false, is_lost: false }, + { name: 'Negotiation', position: 4, color: '#F59E0B', is_won: false, is_lost: false }, + { name: 'Closed Won', position: 5, color: '#10B981', is_won: true, is_lost: false }, + { name: 'Closed Lost', position: 6, color: '#EF4444', is_won: false, is_lost: true }, + ]; + + const stages = defaultStages.map((s) => + this.pipelineStageRepository.create({ + ...s, + tenant_id: tenantId, + }), + ); + + await this.pipelineStageRepository.save(stages); + + return this.getStages(tenantId); + } +} diff --git a/apps/backend/src/modules/sales/services/sales-dashboard.service.ts b/apps/backend/src/modules/sales/services/sales-dashboard.service.ts new file mode 100644 index 00000000..abfe5485 --- /dev/null +++ b/apps/backend/src/modules/sales/services/sales-dashboard.service.ts @@ -0,0 +1,417 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { Lead, LeadStatus } from '../entities/lead.entity'; +import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; +import { Activity, ActivityStatus } from '../entities/activity.entity'; + +export interface SalesSummary { + leads: { + total: number; + new: number; + qualified: number; + converted: number; + conversionRate: number; + }; + opportunities: { + total: number; + open: number; + won: number; + lost: number; + totalValue: number; + wonValue: number; + avgDealSize: number; + winRate: number; + }; + activities: { + total: number; + pending: number; + completed: number; + overdue: number; + }; + pipeline: { + totalValue: number; + weightedValue: number; + byStage: Array<{ + stage: OpportunityStage; + count: number; + value: number; + }>; + }; +} + +export interface ConversionRates { + leadToOpportunity: number; + opportunityToWon: number; + overall: number; + bySource: Array<{ + source: string; + leads: number; + converted: number; + rate: number; + }>; + byMonth: Array<{ + month: string; + leads: number; + opportunities: number; + won: number; + }>; +} + +export interface RevenueReport { + total: number; + byMonth: Array<{ + month: string; + revenue: number; + deals: number; + }>; + byUser: Array<{ + userId: string; + userName: string; + revenue: number; + deals: number; + }>; +} + +export interface TopSeller { + userId: string; + userName: string; + revenue: number; + deals: number; + avgDealSize: number; + winRate: number; +} + +@Injectable() +export class SalesDashboardService { + constructor( + @InjectRepository(Lead) + private readonly leadRepository: Repository, + @InjectRepository(Opportunity) + private readonly opportunityRepository: Repository, + @InjectRepository(Activity) + private readonly activityRepository: Repository, + ) {} + + async getSummary(tenantId: string): Promise { + const now = new Date(); + + // Leads stats + const leads = await this.leadRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const newLeads = leads.filter((l) => l.status === LeadStatus.NEW); + const qualifiedLeads = leads.filter((l) => l.status === LeadStatus.QUALIFIED); + const convertedLeads = leads.filter((l) => l.status === LeadStatus.CONVERTED); + const leadConversionRate = leads.length > 0 + ? (convertedLeads.length / leads.length) * 100 + : 0; + + // Opportunities stats + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const openOpportunities = opportunities.filter((o) => !o.won_at && !o.lost_at); + const wonOpportunities = opportunities.filter((o) => o.won_at); + const lostOpportunities = opportunities.filter((o) => o.lost_at); + + const totalValue = opportunities.reduce((sum, o) => sum + Number(o.amount), 0); + const wonValue = wonOpportunities.reduce((sum, o) => sum + Number(o.amount), 0); + const closedCount = wonOpportunities.length + lostOpportunities.length; + const winRate = closedCount > 0 ? (wonOpportunities.length / closedCount) * 100 : 0; + const avgDealSize = wonOpportunities.length > 0 ? wonValue / wonOpportunities.length : 0; + + // Activities stats + const activities = await this.activityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const pendingActivities = activities.filter((a) => a.status === ActivityStatus.PENDING); + const completedActivities = activities.filter((a) => a.status === ActivityStatus.COMPLETED); + const overdueActivities = pendingActivities.filter( + (a) => a.due_date && new Date(a.due_date) < now, + ); + + // Pipeline stats + const pipelineOpportunities = openOpportunities; + const pipelineTotalValue = pipelineOpportunities.reduce((sum, o) => sum + Number(o.amount), 0); + const pipelineWeightedValue = pipelineOpportunities.reduce( + (sum, o) => sum + Number(o.amount) * (o.probability / 100), + 0, + ); + + const byStage = Object.values(OpportunityStage).map((stage) => { + const stageOpps = pipelineOpportunities.filter((o) => o.stage === stage); + return { + stage, + count: stageOpps.length, + value: stageOpps.reduce((sum, o) => sum + Number(o.amount), 0), + }; + }); + + return { + leads: { + total: leads.length, + new: newLeads.length, + qualified: qualifiedLeads.length, + converted: convertedLeads.length, + conversionRate: Math.round(leadConversionRate), + }, + opportunities: { + total: opportunities.length, + open: openOpportunities.length, + won: wonOpportunities.length, + lost: lostOpportunities.length, + totalValue, + wonValue, + avgDealSize: Math.round(avgDealSize), + winRate: Math.round(winRate), + }, + activities: { + total: activities.length, + pending: pendingActivities.length, + completed: completedActivities.length, + overdue: overdueActivities.length, + }, + pipeline: { + totalValue: pipelineTotalValue, + weightedValue: pipelineWeightedValue, + byStage, + }, + }; + } + + async getConversionRates( + tenantId: string, + startDate?: Date, + endDate?: Date, + ): Promise { + const leads = await this.leadRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + }); + + // Filter by date if provided + const filteredLeads = startDate && endDate + ? leads.filter((l) => { + const created = new Date(l.created_at); + return created >= startDate && created <= endDate; + }) + : leads; + + const filteredOpportunities = startDate && endDate + ? opportunities.filter((o) => { + const created = new Date(o.created_at); + return created >= startDate && created <= endDate; + }) + : opportunities; + + const convertedLeads = filteredLeads.filter((l) => l.status === LeadStatus.CONVERTED); + const wonOpportunities = filteredOpportunities.filter((o) => o.won_at); + const closedOpportunities = filteredOpportunities.filter((o) => o.won_at || o.lost_at); + + const leadToOpportunity = filteredLeads.length > 0 + ? (convertedLeads.length / filteredLeads.length) * 100 + : 0; + + const opportunityToWon = closedOpportunities.length > 0 + ? (wonOpportunities.length / closedOpportunities.length) * 100 + : 0; + + const overall = filteredLeads.length > 0 + ? (wonOpportunities.length / filteredLeads.length) * 100 + : 0; + + // By source + const sourceMap: Record = {}; + filteredLeads.forEach((lead) => { + const source = lead.source || 'unknown'; + if (!sourceMap[source]) { + sourceMap[source] = { leads: 0, converted: 0 }; + } + sourceMap[source].leads++; + if (lead.status === LeadStatus.CONVERTED) { + sourceMap[source].converted++; + } + }); + + const bySource = Object.entries(sourceMap).map(([source, data]) => ({ + source, + leads: data.leads, + converted: data.converted, + rate: data.leads > 0 ? Math.round((data.converted / data.leads) * 100) : 0, + })); + + // By month (last 6 months) + const byMonth: Array<{ + month: string; + leads: number; + opportunities: number; + won: number; + }> = []; + + for (let i = 5; i >= 0; i--) { + const date = new Date(); + date.setMonth(date.getMonth() - i); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + const monthLeads = filteredLeads.filter((l) => { + const created = new Date(l.created_at); + return `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}` === monthKey; + }); + + const monthOpportunities = filteredOpportunities.filter((o) => { + const created = new Date(o.created_at); + return `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}` === monthKey; + }); + + const monthWon = monthOpportunities.filter((o) => o.won_at); + + byMonth.push({ + month: monthKey, + leads: monthLeads.length, + opportunities: monthOpportunities.length, + won: monthWon.length, + }); + } + + return { + leadToOpportunity: Math.round(leadToOpportunity), + opportunityToWon: Math.round(opportunityToWon), + overall: Math.round(overall), + bySource, + byMonth, + }; + } + + async getRevenueByPeriod( + tenantId: string, + startDate: Date, + endDate: Date, + ): Promise { + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + relations: ['assignedUser'], + }); + + const wonInPeriod = opportunities.filter((o) => { + if (!o.won_at) return false; + const wonDate = new Date(o.won_at); + return wonDate >= startDate && wonDate <= endDate; + }); + + const total = wonInPeriod.reduce((sum, o) => sum + Number(o.amount), 0); + + // By month + const monthMap: Record = {}; + wonInPeriod.forEach((o) => { + const date = new Date(o.won_at!); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + if (!monthMap[monthKey]) { + monthMap[monthKey] = { revenue: 0, deals: 0 }; + } + monthMap[monthKey].revenue += Number(o.amount); + monthMap[monthKey].deals++; + }); + + const byMonth = Object.entries(monthMap) + .map(([month, data]) => ({ month, ...data })) + .sort((a, b) => a.month.localeCompare(b.month)); + + // By user + const userMap: Record = {}; + wonInPeriod.forEach((o) => { + if (!o.assigned_to) return; + if (!userMap[o.assigned_to]) { + userMap[o.assigned_to] = { + userName: o.assignedUser?.email || 'Unknown', + revenue: 0, + deals: 0, + }; + } + userMap[o.assigned_to].revenue += Number(o.amount); + userMap[o.assigned_to].deals++; + }); + + const byUser = Object.entries(userMap) + .map(([userId, data]) => ({ userId, ...data })) + .sort((a, b) => b.revenue - a.revenue); + + return { total, byMonth, byUser }; + } + + async getTopSellers( + tenantId: string, + limit: number = 10, + startDate?: Date, + endDate?: Date, + ): Promise { + const opportunities = await this.opportunityRepository.find({ + where: { tenant_id: tenantId, deleted_at: undefined }, + relations: ['assignedUser'], + }); + + // Filter by date if provided + let filteredOpportunities = opportunities; + if (startDate && endDate) { + filteredOpportunities = opportunities.filter((o) => { + if (!o.won_at && !o.lost_at) return false; + const closeDate = new Date(o.won_at || o.lost_at!); + return closeDate >= startDate && closeDate <= endDate; + }); + } + + const userStats: Record< + string, + { + userName: string; + revenue: number; + deals: number; + won: number; + closed: number; + } + > = {}; + + filteredOpportunities.forEach((o) => { + if (!o.assigned_to) return; + + if (!userStats[o.assigned_to]) { + userStats[o.assigned_to] = { + userName: o.assignedUser?.email || 'Unknown', + revenue: 0, + deals: 0, + won: 0, + closed: 0, + }; + } + + if (o.won_at) { + userStats[o.assigned_to].revenue += Number(o.amount); + userStats[o.assigned_to].deals++; + userStats[o.assigned_to].won++; + userStats[o.assigned_to].closed++; + } else if (o.lost_at) { + userStats[o.assigned_to].closed++; + } + }); + + const topSellers: TopSeller[] = Object.entries(userStats) + .map(([userId, stats]) => ({ + userId, + userName: stats.userName, + revenue: stats.revenue, + deals: stats.deals, + avgDealSize: stats.deals > 0 ? Math.round(stats.revenue / stats.deals) : 0, + winRate: stats.closed > 0 ? Math.round((stats.won / stats.closed) * 100) : 0, + })) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, limit); + + return topSellers; + } +} diff --git a/apps/frontend/src/components/sales/ActivityForm.tsx b/apps/frontend/src/components/sales/ActivityForm.tsx new file mode 100644 index 00000000..e6a26589 --- /dev/null +++ b/apps/frontend/src/components/sales/ActivityForm.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react'; +import { X } from 'lucide-react'; +import { useCreateActivity } from '../../hooks/sales'; +import { CreateActivityDto, ActivityType } from '../../services/sales/activities.api'; + +interface ActivityFormProps { + leadId?: string; + opportunityId?: string; + onClose: () => void; +} + +export function ActivityForm({ leadId, opportunityId, onClose }: ActivityFormProps) { + const [formData, setFormData] = useState({ + type: 'task', + subject: '', + description: '', + lead_id: leadId, + opportunity_id: opportunityId, + due_date: '', + duration_minutes: undefined, + }); + + const createActivity = useCreateActivity(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await createActivity.mutateAsync(formData); + onClose(); + }; + + const isLoading = createActivity.isPending; + + return ( +
+
+
+

Add Activity

+ +
+ +
+
+ + +
+ +
+ + setFormData({ ...formData, subject: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + placeholder="e.g., Follow up call, Send proposal..." + /> +
+ +
+ +