[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 <noreply@anthropic.com>
This commit is contained in:
parent
806612a4db
commit
529ea53b5e
@ -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 {}
|
||||
|
||||
@ -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<Repository<Activity>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockActivity: Partial<Activity> = {
|
||||
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>(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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<LeadsService>;
|
||||
|
||||
const mockTenantId = 'tenant-123';
|
||||
const mockUserId = 'user-123';
|
||||
|
||||
const mockLead: Partial<Lead> = {
|
||||
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<Opportunity> = {
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
341
apps/backend/src/modules/sales/__tests__/leads.service.spec.ts
Normal file
341
apps/backend/src/modules/sales/__tests__/leads.service.spec.ts
Normal file
@ -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<Repository<Lead>>;
|
||||
let opportunityRepo: jest.Mocked<Repository<Opportunity>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockLead: Partial<Lead> = {
|
||||
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<Opportunity> = {
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<OpportunitiesService>;
|
||||
|
||||
const mockTenantId = 'tenant-123';
|
||||
const mockUserId = 'user-123';
|
||||
|
||||
const mockOpportunity: Partial<Opportunity> = {
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<Repository<Opportunity>>;
|
||||
let pipelineStageRepo: jest.Mocked<Repository<PipelineStage>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockOpportunity: Partial<Opportunity> = {
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
5
apps/backend/src/modules/sales/controllers/index.ts
Normal file
5
apps/backend/src/modules/sales/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './leads.controller';
|
||||
export * from './opportunities.controller';
|
||||
export * from './pipeline.controller';
|
||||
export * from './activities.controller';
|
||||
export * from './dashboard.controller';
|
||||
143
apps/backend/src/modules/sales/controllers/leads.controller.ts
Normal file
143
apps/backend/src/modules/sales/controllers/leads.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
25
apps/backend/src/modules/sales/dto/convert-lead.dto.ts
Normal file
25
apps/backend/src/modules/sales/dto/convert-lead.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
83
apps/backend/src/modules/sales/dto/create-activity.dto.ts
Normal file
83
apps/backend/src/modules/sales/dto/create-activity.dto.ts
Normal file
@ -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<string, unknown>;
|
||||
}
|
||||
100
apps/backend/src/modules/sales/dto/create-lead.dto.ts
Normal file
100
apps/backend/src/modules/sales/dto/create-lead.dto.ts
Normal file
@ -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<string, unknown>;
|
||||
}
|
||||
87
apps/backend/src/modules/sales/dto/create-opportunity.dto.ts
Normal file
87
apps/backend/src/modules/sales/dto/create-opportunity.dto.ts
Normal file
@ -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<string, unknown>;
|
||||
}
|
||||
8
apps/backend/src/modules/sales/dto/index.ts
Normal file
8
apps/backend/src/modules/sales/dto/index.ts
Normal file
@ -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';
|
||||
19
apps/backend/src/modules/sales/dto/move-opportunity.dto.ts
Normal file
19
apps/backend/src/modules/sales/dto/move-opportunity.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateActivityDto } from './create-activity.dto';
|
||||
|
||||
export class UpdateActivityDto extends PartialType(CreateActivityDto) {}
|
||||
4
apps/backend/src/modules/sales/dto/update-lead.dto.ts
Normal file
4
apps/backend/src/modules/sales/dto/update-lead.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateLeadDto } from './create-lead.dto';
|
||||
|
||||
export class UpdateLeadDto extends PartialType(CreateLeadDto) {}
|
||||
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateOpportunityDto } from './create-opportunity.dto';
|
||||
|
||||
export class UpdateOpportunityDto extends PartialType(CreateOpportunityDto) {}
|
||||
145
apps/backend/src/modules/sales/entities/activity.entity.ts
Normal file
145
apps/backend/src/modules/sales/entities/activity.entity.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
4
apps/backend/src/modules/sales/entities/index.ts
Normal file
4
apps/backend/src/modules/sales/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './lead.entity';
|
||||
export * from './opportunity.entity';
|
||||
export * from './pipeline-stage.entity';
|
||||
export * from './activity.entity';
|
||||
130
apps/backend/src/modules/sales/entities/lead.entity.ts
Normal file
130
apps/backend/src/modules/sales/entities/lead.entity.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp with time zone' })
|
||||
updated_at: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
128
apps/backend/src/modules/sales/entities/opportunity.entity.ts
Normal file
128
apps/backend/src/modules/sales/entities/opportunity.entity.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp with time zone' })
|
||||
updated_at: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
55
apps/backend/src/modules/sales/sales.module.ts
Normal file
55
apps/backend/src/modules/sales/sales.module.ts
Normal file
@ -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 {}
|
||||
275
apps/backend/src/modules/sales/services/activities.service.ts
Normal file
275
apps/backend/src/modules/sales/services/activities.service.ts
Normal file
@ -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<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivitiesService {
|
||||
constructor(
|
||||
@InjectRepository(Activity)
|
||||
private readonly activityRepository: Repository<Activity>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: ActivityFilters = {},
|
||||
pagination: PaginationOptions = {},
|
||||
): Promise<PaginatedResult<Activity>> {
|
||||
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<Activity> {
|
||||
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<Activity> {
|
||||
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<Activity> {
|
||||
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<void> {
|
||||
const activity = await this.findOne(tenantId, id);
|
||||
activity.deleted_at = new Date();
|
||||
await this.activityRepository.save(activity);
|
||||
}
|
||||
|
||||
async findByLead(tenantId: string, leadId: string): Promise<Activity[]> {
|
||||
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<Activity[]> {
|
||||
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<Activity[]> {
|
||||
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<Activity[]> {
|
||||
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<Activity> {
|
||||
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<Activity> {
|
||||
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<ActivityType, number>;
|
||||
}> {
|
||||
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<ActivityType, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
total: activities.length,
|
||||
pending: pending.length,
|
||||
completed: completed.length,
|
||||
overdue: overdue.length,
|
||||
byType,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
apps/backend/src/modules/sales/services/index.ts
Normal file
5
apps/backend/src/modules/sales/services/index.ts
Normal file
@ -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';
|
||||
235
apps/backend/src/modules/sales/services/leads.service.ts
Normal file
235
apps/backend/src/modules/sales/services/leads.service.ts
Normal file
@ -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<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
constructor(
|
||||
@InjectRepository(Lead)
|
||||
private readonly leadRepository: Repository<Lead>,
|
||||
@InjectRepository(Opportunity)
|
||||
private readonly opportunityRepository: Repository<Opportunity>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: LeadFilters = {},
|
||||
pagination: PaginationOptions = {},
|
||||
): Promise<PaginatedResult<Lead>> {
|
||||
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: FindOptionsWhere<Lead> = {
|
||||
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<Lead> {
|
||||
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<Lead> {
|
||||
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<Lead> {
|
||||
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<void> {
|
||||
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<Opportunity> {
|
||||
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<Lead> {
|
||||
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<Lead> {
|
||||
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<LeadStatus, number>;
|
||||
bySource: Record<LeadSource, number>;
|
||||
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<LeadStatus, number>,
|
||||
);
|
||||
|
||||
const bySource = leads.reduce(
|
||||
(acc, lead) => {
|
||||
acc[lead.source] = (acc[lead.source] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<LeadSource, number>,
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
342
apps/backend/src/modules/sales/services/opportunities.service.ts
Normal file
342
apps/backend/src/modules/sales/services/opportunities.service.ts
Normal file
@ -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<T> {
|
||||
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<Opportunity>,
|
||||
@InjectRepository(PipelineStage)
|
||||
private readonly pipelineStageRepository: Repository<PipelineStage>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: OpportunityFilters = {},
|
||||
pagination: PaginationOptions = {},
|
||||
): Promise<PaginatedResult<Opportunity>> {
|
||||
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<Opportunity> {
|
||||
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<Opportunity> {
|
||||
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<Opportunity> {
|
||||
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<void> {
|
||||
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<Opportunity> {
|
||||
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<Opportunity> {
|
||||
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<Opportunity> {
|
||||
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<PipelineView[]> {
|
||||
const stages = Object.values(OpportunityStage);
|
||||
const stageNames: Record<OpportunityStage, string> = {
|
||||
[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<string, { amount: number; weighted: number }> = {};
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
177
apps/backend/src/modules/sales/services/pipeline.service.ts
Normal file
177
apps/backend/src/modules/sales/services/pipeline.service.ts
Normal file
@ -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<PipelineStage>,
|
||||
@InjectRepository(Opportunity)
|
||||
private readonly opportunityRepository: Repository<Opportunity>,
|
||||
) {}
|
||||
|
||||
async getStages(tenantId: string): Promise<PipelineStageWithCount[]> {
|
||||
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<PipelineStage> {
|
||||
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<PipelineStage>,
|
||||
): Promise<PipelineStage> {
|
||||
// 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<PipelineStage>,
|
||||
): Promise<PipelineStage> {
|
||||
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<void> {
|
||||
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<PipelineStage[]> {
|
||||
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<PipelineStage[]> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@ -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<Lead>,
|
||||
@InjectRepository(Opportunity)
|
||||
private readonly opportunityRepository: Repository<Opportunity>,
|
||||
@InjectRepository(Activity)
|
||||
private readonly activityRepository: Repository<Activity>,
|
||||
) {}
|
||||
|
||||
async getSummary(tenantId: string): Promise<SalesSummary> {
|
||||
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<ConversionRates> {
|
||||
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<string, { leads: number; converted: number }> = {};
|
||||
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<RevenueReport> {
|
||||
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<string, { revenue: number; deals: number }> = {};
|
||||
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<string, { userName: string; revenue: number; deals: number }> = {};
|
||||
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<TopSeller[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
181
apps/frontend/src/components/sales/ActivityForm.tsx
Normal file
181
apps/frontend/src/components/sales/ActivityForm.tsx
Normal file
@ -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<CreateActivityDto>({
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Add Activity</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Type *
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as ActivityType })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="call">Call</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subject *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.subject}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add details about this activity..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.duration_minutes || ''}
|
||||
onChange={(e) => setFormData({ ...formData, duration_minutes: parseInt(e.target.value) || undefined })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., 30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.type === 'call' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Call Direction
|
||||
</label>
|
||||
<select
|
||||
value={formData.call_direction || ''}
|
||||
onChange={(e) => setFormData({ ...formData, call_direction: e.target.value as 'inbound' | 'outbound' })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="outbound">Outbound</option>
|
||||
<option value="inbound">Inbound</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.type === 'meeting' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location || ''}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Office, client site..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Meeting URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.meeting_url || ''}
|
||||
onChange={(e) => setFormData({ ...formData, meeting_url: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Activity'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/frontend/src/components/sales/ActivityTimeline.tsx
Normal file
118
apps/frontend/src/components/sales/ActivityTimeline.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { Phone, Users, CheckSquare, Mail, FileText, Clock, CheckCircle } from 'lucide-react';
|
||||
import { Activity, ActivityType, ActivityStatus } from '../../services/sales/activities.api';
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export function ActivityTimeline({ activities }: ActivityTimelineProps) {
|
||||
const getIcon = (type: ActivityType) => {
|
||||
switch (type) {
|
||||
case 'call':
|
||||
return <Phone className="w-4 h-4" />;
|
||||
case 'meeting':
|
||||
return <Users className="w-4 h-4" />;
|
||||
case 'task':
|
||||
return <CheckSquare className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'note':
|
||||
return <FileText className="w-4 h-4" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getIconBgColor = (type: ActivityType) => {
|
||||
switch (type) {
|
||||
case 'call':
|
||||
return 'bg-green-100 text-green-600';
|
||||
case 'meeting':
|
||||
return 'bg-blue-100 text-blue-600';
|
||||
case 'task':
|
||||
return 'bg-yellow-100 text-yellow-600';
|
||||
case 'email':
|
||||
return 'bg-purple-100 text-purple-600';
|
||||
case 'note':
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ActivityStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No activities yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity, index) => (
|
||||
<div key={activity.id} className="relative flex gap-4">
|
||||
{/* Icon */}
|
||||
<div className={`relative z-10 flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${getIconBgColor(activity.type)}`}>
|
||||
{getIcon(activity.type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-gray-900">{activity.subject}</h4>
|
||||
{getStatusIcon(activity.status)}
|
||||
</div>
|
||||
{activity.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{activity.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
{new Date(activity.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Activity details */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span className="capitalize">{activity.type}</span>
|
||||
{activity.due_date && (
|
||||
<span>Due: {new Date(activity.due_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
{activity.duration_minutes && (
|
||||
<span>{activity.duration_minutes} min</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outcome */}
|
||||
{activity.outcome && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Outcome:</span> {activity.outcome}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
apps/frontend/src/components/sales/ConversionFunnel.tsx
Normal file
68
apps/frontend/src/components/sales/ConversionFunnel.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { ConversionRates } from '../../services/sales/dashboard.api';
|
||||
|
||||
interface ConversionFunnelProps {
|
||||
conversion: ConversionRates;
|
||||
}
|
||||
|
||||
export function ConversionFunnel({ conversion }: ConversionFunnelProps) {
|
||||
const funnelStages = [
|
||||
{ name: 'Leads', value: 100, color: 'bg-blue-500' },
|
||||
{ name: 'Opportunities', value: conversion.leadToOpportunity, color: 'bg-purple-500' },
|
||||
{ name: 'Won', value: conversion.overall, color: 'bg-green-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Funnel Visualization */}
|
||||
<div className="relative">
|
||||
{funnelStages.map((stage, index) => {
|
||||
const widthPercent = 100 - (index * 20);
|
||||
return (
|
||||
<div
|
||||
key={stage.name}
|
||||
className="relative mx-auto mb-1"
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
>
|
||||
<div
|
||||
className={`${stage.color} h-12 rounded-md flex items-center justify-center`}
|
||||
>
|
||||
<span className="text-white font-medium">{stage.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Conversion Rates */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-purple-600">{conversion.leadToOpportunity}%</p>
|
||||
<p className="text-xs text-gray-500">Lead → Opportunity</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{conversion.opportunityToWon}%</p>
|
||||
<p className="text-xs text-gray-500">Opportunity → Won</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">{conversion.overall}%</p>
|
||||
<p className="text-xs text-gray-500">Overall</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Source */}
|
||||
{conversion.bySource.length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-3">Conversion by Source</h4>
|
||||
<div className="space-y-2">
|
||||
{conversion.bySource.slice(0, 5).map((source) => (
|
||||
<div key={source.source} className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize">{source.source.replace('_', ' ')}</span>
|
||||
<span className="font-medium">{source.rate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/frontend/src/components/sales/LeadCard.tsx
Normal file
122
apps/frontend/src/components/sales/LeadCard.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { Lead } from '../../services/sales/leads.api';
|
||||
import { Mail, Phone, Building, Briefcase, Globe, MapPin, Star } from 'lucide-react';
|
||||
|
||||
interface LeadCardProps {
|
||||
lead: Lead;
|
||||
}
|
||||
|
||||
export function LeadCard({ lead }: LeadCardProps) {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'contacted':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'qualified':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'unqualified':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'converted':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = (source: string) => {
|
||||
return source.replace('_', ' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full capitalize ${getStatusColor(lead.status)}`}>
|
||||
{lead.status}
|
||||
</span>
|
||||
<span className="ml-2 px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full capitalize">
|
||||
{getSourceLabel(lead.source)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-yellow-500">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<span className="text-sm font-medium">{lead.score}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-gray-900 border-b pb-2">Contact Information</h3>
|
||||
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
<a href={`mailto:${lead.email}`} className="text-blue-600 hover:underline">
|
||||
{lead.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
<a href={`tel:${lead.phone}`} className="text-blue-600 hover:underline">
|
||||
{lead.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lead.company && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="w-4 h-4 text-gray-400" />
|
||||
<span>{lead.company}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lead.job_title && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Briefcase className="w-4 h-4 text-gray-400" />
|
||||
<span>{lead.job_title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lead.website && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-4 h-4 text-gray-400" />
|
||||
<a href={lead.website} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
{lead.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-gray-900 border-b pb-2">Address</h3>
|
||||
|
||||
{(lead.address_line1 || lead.city || lead.state || lead.country) ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
{lead.address_line1 && <p>{lead.address_line1}</p>}
|
||||
{lead.address_line2 && <p>{lead.address_line2}</p>}
|
||||
<p>
|
||||
{[lead.city, lead.state, lead.postal_code].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
{lead.country && <p>{lead.country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">No address provided</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Created: {new Date(lead.created_at).toLocaleDateString()}</span>
|
||||
<span>Updated: {new Date(lead.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/frontend/src/components/sales/LeadForm.tsx
Normal file
199
apps/frontend/src/components/sales/LeadForm.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useCreateLead, useUpdateLead } from '../../hooks/sales';
|
||||
import { Lead, CreateLeadDto, LeadSource, LeadStatus } from '../../services/sales/leads.api';
|
||||
|
||||
interface LeadFormProps {
|
||||
lead?: Lead;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LeadForm({ lead, onClose }: LeadFormProps) {
|
||||
const [formData, setFormData] = useState<CreateLeadDto>({
|
||||
first_name: lead?.first_name || '',
|
||||
last_name: lead?.last_name || '',
|
||||
email: lead?.email || '',
|
||||
phone: lead?.phone || '',
|
||||
company: lead?.company || '',
|
||||
job_title: lead?.job_title || '',
|
||||
website: lead?.website || '',
|
||||
source: lead?.source || 'other',
|
||||
status: lead?.status || 'new',
|
||||
score: lead?.score || 0,
|
||||
notes: lead?.notes || '',
|
||||
});
|
||||
|
||||
const createLead = useCreateLead();
|
||||
const updateLead = useUpdateLead();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lead) {
|
||||
await updateLead.mutateAsync({ id: lead.id, data: formData });
|
||||
} else {
|
||||
await createLead.mutateAsync(formData);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isLoading = createLead.isPending || updateLead.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{lead ? 'Edit Lead' : 'Add New Lead'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Job Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.job_title}
|
||||
onChange={(e) => setFormData({ ...formData, job_title: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source
|
||||
</label>
|
||||
<select
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData({ ...formData, source: e.target.value as LeadSource })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="website">Website</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="cold_call">Cold Call</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="advertisement">Advertisement</option>
|
||||
<option value="social_media">Social Media</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as LeadStatus })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="new">New</option>
|
||||
<option value="contacted">Contacted</option>
|
||||
<option value="qualified">Qualified</option>
|
||||
<option value="unqualified">Unqualified</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : lead ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
apps/frontend/src/components/sales/LeadsList.tsx
Normal file
138
apps/frontend/src/components/sales/LeadsList.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lead } from '../../services/sales/leads.api';
|
||||
|
||||
interface LeadsListProps {
|
||||
leads: Lead[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function LeadsList({ leads, total, page, totalPages, onPageChange }: LeadsListProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'contacted':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'qualified':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'unqualified':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'converted':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (leads.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-8 text-center">
|
||||
<p className="text-gray-500">No leads found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Company
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Score
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{leads.map((lead) => (
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => navigate(`/sales/leads/${lead.id}`)}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{lead.first_name} {lead.last_name}
|
||||
</div>
|
||||
{lead.email && (
|
||||
<div className="text-sm text-gray-500">{lead.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{lead.company || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full capitalize ${getStatusColor(lead.status)}`}>
|
||||
{lead.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||
{lead.source.replace('_', ' ')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${lead.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">{lead.score}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(lead.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 bg-gray-50 border-t flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, total)} of {total} leads
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/frontend/src/components/sales/OpportunityCard.tsx
Normal file
75
apps/frontend/src/components/sales/OpportunityCard.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { DollarSign, Calendar, User } from 'lucide-react';
|
||||
import { Opportunity } from '../../services/sales/opportunities.api';
|
||||
|
||||
interface OpportunityCardProps {
|
||||
opportunity: Opportunity;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function OpportunityCard({ opportunity, compact = false, onClick }: OpportunityCardProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: opportunity.currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white p-2 rounded border hover:shadow-sm cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{opportunity.name}</span>
|
||||
<span className="text-sm font-semibold text-green-600">
|
||||
{formatCurrency(opportunity.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white p-3 rounded-lg border hover:shadow-md cursor-pointer transition-shadow"
|
||||
>
|
||||
<h4 className="font-medium text-gray-900 truncate">{opportunity.name}</h4>
|
||||
{opportunity.company_name && (
|
||||
<p className="text-sm text-gray-500 truncate">{opportunity.company_name}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span className="font-semibold">{formatCurrency(opportunity.amount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm">
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-xs">
|
||||
{opportunity.probability}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(opportunity.expected_close_date || opportunity.contact_name) && (
|
||||
<div className="mt-2 pt-2 border-t flex items-center justify-between text-xs text-gray-400">
|
||||
{opportunity.expected_close_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{new Date(opportunity.expected_close_date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{opportunity.contact_name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{opportunity.contact_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
apps/frontend/src/components/sales/OpportunityForm.tsx
Normal file
235
apps/frontend/src/components/sales/OpportunityForm.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useCreateOpportunity, useUpdateOpportunity } from '../../hooks/sales';
|
||||
import { Opportunity, CreateOpportunityDto, OpportunityStage } from '../../services/sales/opportunities.api';
|
||||
|
||||
interface OpportunityFormProps {
|
||||
opportunity?: Opportunity;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OpportunityForm({ opportunity, onClose }: OpportunityFormProps) {
|
||||
const [formData, setFormData] = useState<CreateOpportunityDto>({
|
||||
name: opportunity?.name || '',
|
||||
description: opportunity?.description || '',
|
||||
stage: opportunity?.stage || 'prospecting',
|
||||
amount: opportunity?.amount || 0,
|
||||
currency: opportunity?.currency || 'USD',
|
||||
probability: opportunity?.probability || 0,
|
||||
expected_close_date: opportunity?.expected_close_date?.split('T')[0] || '',
|
||||
contact_name: opportunity?.contact_name || '',
|
||||
contact_email: opportunity?.contact_email || '',
|
||||
contact_phone: opportunity?.contact_phone || '',
|
||||
company_name: opportunity?.company_name || '',
|
||||
notes: opportunity?.notes || '',
|
||||
});
|
||||
|
||||
const createOpportunity = useCreateOpportunity();
|
||||
const updateOpportunity = useUpdateOpportunity();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (opportunity) {
|
||||
await updateOpportunity.mutateAsync({ id: opportunity.id, data: formData });
|
||||
} else {
|
||||
await createOpportunity.mutateAsync(formData);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isLoading = createOpportunity.isPending || updateOpportunity.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{opportunity ? 'Edit Opportunity' : 'Add New Opportunity'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Opportunity Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Amount *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Probability (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.probability}
|
||||
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stage
|
||||
</label>
|
||||
<select
|
||||
value={formData.stage}
|
||||
onChange={(e) => setFormData({ ...formData, stage: e.target.value as OpportunityStage })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="prospecting">Prospecting</option>
|
||||
<option value="qualification">Qualification</option>
|
||||
<option value="proposal">Proposal</option>
|
||||
<option value="negotiation">Negotiation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Expected Close Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expected_close_date}
|
||||
onChange={(e) => setFormData({ ...formData, expected_close_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h3 className="font-medium mb-3">Contact Information</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contact Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contact_name}
|
||||
onChange={(e) => setFormData({ ...formData, contact_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => setFormData({ ...formData, company_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.contact_email}
|
||||
onChange={(e) => setFormData({ ...formData, contact_email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.contact_phone}
|
||||
onChange={(e) => setFormData({ ...formData, contact_phone: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : opportunity ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
apps/frontend/src/components/sales/PipelineBoard.tsx
Normal file
146
apps/frontend/src/components/sales/PipelineBoard.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PipelineView } from '../../services/sales/opportunities.api';
|
||||
import { OpportunityCard } from './OpportunityCard';
|
||||
|
||||
interface PipelineBoardProps {
|
||||
stages: PipelineView[];
|
||||
}
|
||||
|
||||
export function PipelineBoard({ stages }: PipelineBoardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getStageColor = (stage: string) => {
|
||||
switch (stage) {
|
||||
case 'prospecting':
|
||||
return 'bg-gray-100 border-gray-300';
|
||||
case 'qualification':
|
||||
return 'bg-blue-50 border-blue-300';
|
||||
case 'proposal':
|
||||
return 'bg-purple-50 border-purple-300';
|
||||
case 'negotiation':
|
||||
return 'bg-yellow-50 border-yellow-300';
|
||||
case 'closed_won':
|
||||
return 'bg-green-50 border-green-300';
|
||||
case 'closed_lost':
|
||||
return 'bg-red-50 border-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getHeaderColor = (stage: string) => {
|
||||
switch (stage) {
|
||||
case 'prospecting':
|
||||
return 'bg-gray-500';
|
||||
case 'qualification':
|
||||
return 'bg-blue-500';
|
||||
case 'proposal':
|
||||
return 'bg-purple-500';
|
||||
case 'negotiation':
|
||||
return 'bg-yellow-500';
|
||||
case 'closed_won':
|
||||
return 'bg-green-500';
|
||||
case 'closed_lost':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out closed stages for the main pipeline view
|
||||
const openStages = stages.filter(
|
||||
(s) => s.stage !== 'closed_won' && s.stage !== 'closed_lost'
|
||||
);
|
||||
const closedStages = stages.filter(
|
||||
(s) => s.stage === 'closed_won' || s.stage === 'closed_lost'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Open Pipeline */}
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{openStages.map((stage) => (
|
||||
<div
|
||||
key={stage.stage}
|
||||
className={`flex-shrink-0 w-80 rounded-lg border-2 ${getStageColor(stage.stage)}`}
|
||||
>
|
||||
{/* Stage Header */}
|
||||
<div className={`${getHeaderColor(stage.stage)} text-white p-3 rounded-t-md`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">{stage.stageName}</h3>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded text-sm">
|
||||
{stage.count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-90 mt-1">
|
||||
{formatCurrency(stage.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Opportunities */}
|
||||
<div className="p-2 space-y-2 min-h-[200px] max-h-[500px] overflow-y-auto">
|
||||
{stage.opportunities.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
No opportunities
|
||||
</div>
|
||||
) : (
|
||||
stage.opportunities.map((opp) => (
|
||||
<OpportunityCard
|
||||
key={opp.id}
|
||||
opportunity={opp}
|
||||
onClick={() => navigate(`/sales/opportunities/${opp.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Closed Stages */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{closedStages.map((stage) => (
|
||||
<div
|
||||
key={stage.stage}
|
||||
className={`rounded-lg border-2 ${getStageColor(stage.stage)}`}
|
||||
>
|
||||
<div className={`${getHeaderColor(stage.stage)} text-white p-3 rounded-t-md`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">{stage.stageName}</h3>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded text-sm">
|
||||
{stage.count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-90 mt-1">
|
||||
{formatCurrency(stage.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{stage.opportunities.slice(0, 5).map((opp) => (
|
||||
<OpportunityCard
|
||||
key={opp.id}
|
||||
opportunity={opp}
|
||||
compact
|
||||
onClick={() => navigate(`/sales/opportunities/${opp.id}`)}
|
||||
/>
|
||||
))}
|
||||
{stage.count > 5 && (
|
||||
<p className="text-center text-sm text-gray-500 py-2">
|
||||
+{stage.count - 5} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
apps/frontend/src/components/sales/SalesDashboard.tsx
Normal file
155
apps/frontend/src/components/sales/SalesDashboard.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { TrendingUp, Users, DollarSign, Target, ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
||||
import { SalesSummary, ConversionRates } from '../../services/sales/dashboard.api';
|
||||
import { ConversionFunnel } from './ConversionFunnel';
|
||||
|
||||
interface SalesDashboardProps {
|
||||
summary: SalesSummary;
|
||||
conversion: ConversionRates;
|
||||
}
|
||||
|
||||
export function SalesDashboard({ summary, conversion }: SalesDashboardProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Leads</p>
|
||||
<p className="text-3xl font-bold mt-1">{summary.leads.total}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1 text-sm">
|
||||
<span className="text-green-600 flex items-center">
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
{summary.leads.conversionRate}%
|
||||
</span>
|
||||
<span className="text-gray-500">conversion rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Open Opportunities</p>
|
||||
<p className="text-3xl font-bold mt-1">{summary.opportunities.open}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Target className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
{formatCurrency(summary.pipeline.totalValue)} in pipeline
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Won This Period</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatCurrency(summary.opportunities.wonValue)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1 text-sm">
|
||||
<span className="text-green-600">{summary.opportunities.won}</span>
|
||||
<span className="text-gray-500">deals closed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Win Rate</p>
|
||||
<p className="text-3xl font-bold mt-1">{summary.opportunities.winRate}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-100 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Avg deal: {formatCurrency(summary.opportunities.avgDealSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Pipeline by Stage */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="font-semibold mb-4">Pipeline by Stage</h3>
|
||||
<div className="space-y-3">
|
||||
{summary.pipeline.byStage.map((stage) => (
|
||||
<div key={stage.stage} className="flex items-center gap-4">
|
||||
<div className="w-32 text-sm text-gray-600 capitalize">
|
||||
{stage.stage.replace('_', ' ')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{
|
||||
width: `${summary.pipeline.totalValue > 0
|
||||
? (stage.value / summary.pipeline.totalValue) * 100
|
||||
: 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium w-24 text-right">
|
||||
{formatCurrency(stage.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-12 text-right">
|
||||
{stage.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion Funnel */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="font-semibold mb-4">Conversion Funnel</h3>
|
||||
<ConversionFunnel conversion={conversion} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities Overview */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="font-semibold mb-4">Activities Overview</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-blue-600">{summary.activities.pending}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Pending</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-red-600">{summary.activities.overdue}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Overdue</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-green-600">{summary.activities.completed}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Completed</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-gray-600">{summary.activities.total}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/components/sales/index.ts
Normal file
10
apps/frontend/src/components/sales/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export * from './SalesDashboard';
|
||||
export * from './ConversionFunnel';
|
||||
export * from './LeadsList';
|
||||
export * from './LeadForm';
|
||||
export * from './LeadCard';
|
||||
export * from './PipelineBoard';
|
||||
export * from './OpportunityCard';
|
||||
export * from './OpportunityForm';
|
||||
export * from './ActivityTimeline';
|
||||
export * from './ActivityForm';
|
||||
5
apps/frontend/src/hooks/sales/index.ts
Normal file
5
apps/frontend/src/hooks/sales/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './useLeads';
|
||||
export * from './useOpportunities';
|
||||
export * from './useActivities';
|
||||
export * from './usePipeline';
|
||||
export * from './useSalesDashboard';
|
||||
146
apps/frontend/src/hooks/sales/useActivities.ts
Normal file
146
apps/frontend/src/hooks/sales/useActivities.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
activitiesApi,
|
||||
Activity,
|
||||
CreateActivityDto,
|
||||
UpdateActivityDto,
|
||||
ActivityFilters,
|
||||
} from '../../services/sales/activities.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
activities: ['sales', 'activities'] as const,
|
||||
activity: (id: string) => ['sales', 'activities', id] as const,
|
||||
byLead: (leadId: string) => ['sales', 'activities', 'lead', leadId] as const,
|
||||
byOpportunity: (oppId: string) => ['sales', 'activities', 'opportunity', oppId] as const,
|
||||
upcoming: ['sales', 'activities', 'upcoming'] as const,
|
||||
overdue: ['sales', 'activities', 'overdue'] as const,
|
||||
stats: ['sales', 'activities', 'stats'] as const,
|
||||
};
|
||||
|
||||
export function useActivities(filters?: ActivityFilters) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.activities, filters],
|
||||
queryFn: () => activitiesApi.list(filters),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivity(id: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.activity(id),
|
||||
queryFn: () => activitiesApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeadActivities(leadId: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.byLead(leadId),
|
||||
queryFn: () => activitiesApi.getByLead(leadId),
|
||||
enabled: !!leadId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpportunityActivities(opportunityId: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.byOpportunity(opportunityId),
|
||||
queryFn: () => activitiesApi.getByOpportunity(opportunityId),
|
||||
enabled: !!opportunityId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpcomingActivities(days?: number, userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.upcoming, days, userId],
|
||||
queryFn: () => activitiesApi.getUpcoming(days, userId),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverdueActivities(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.overdue, userId],
|
||||
queryFn: () => activitiesApi.getOverdue(userId),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivityStats(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.stats, userId],
|
||||
queryFn: () => activitiesApi.getStats(userId),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateActivity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateActivityDto) => activitiesApi.create(data),
|
||||
onSuccess: (newActivity) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.upcoming });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
if (newActivity.lead_id) {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.byLead(newActivity.lead_id) });
|
||||
}
|
||||
if (newActivity.opportunity_id) {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.byOpportunity(newActivity.opportunity_id) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateActivity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateActivityDto }) =>
|
||||
activitiesApi.update(id, data),
|
||||
onSuccess: (updatedActivity) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activities });
|
||||
queryClient.setQueryData(QUERY_KEYS.activity(updatedActivity.id), updatedActivity);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteActivity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => activitiesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteActivity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, outcome }: { id: string; outcome?: string }) =>
|
||||
activitiesApi.complete(id, outcome),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.upcoming });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.overdue });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelActivity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => activitiesApi.cancel(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.upcoming });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
116
apps/frontend/src/hooks/sales/useLeads.ts
Normal file
116
apps/frontend/src/hooks/sales/useLeads.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
leadsApi,
|
||||
Lead,
|
||||
CreateLeadDto,
|
||||
UpdateLeadDto,
|
||||
ConvertLeadDto,
|
||||
LeadFilters,
|
||||
} from '../../services/sales/leads.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
leads: ['sales', 'leads'] as const,
|
||||
lead: (id: string) => ['sales', 'leads', id] as const,
|
||||
stats: ['sales', 'leads', 'stats'] as const,
|
||||
};
|
||||
|
||||
export function useLeads(filters?: LeadFilters) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.leads, filters],
|
||||
queryFn: () => leadsApi.list(filters),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLead(id: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.lead(id),
|
||||
queryFn: () => leadsApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeadStats() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.stats,
|
||||
queryFn: leadsApi.getStats,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateLeadDto) => leadsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateLeadDto }) =>
|
||||
leadsApi.update(id, data),
|
||||
onSuccess: (updatedLead) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.setQueryData(QUERY_KEYS.lead(updatedLead.id), updatedLead);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => leadsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConvertLead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: ConvertLeadDto }) =>
|
||||
leadsApi.convert(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignLead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||
leadsApi.assign(id, userId),
|
||||
onSuccess: (updatedLead) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.setQueryData(QUERY_KEYS.lead(updatedLead.id), updatedLead);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLeadScore() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, score }: { id: string; score: number }) =>
|
||||
leadsApi.updateScore(id, score),
|
||||
onSuccess: (updatedLead) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.leads });
|
||||
queryClient.setQueryData(QUERY_KEYS.lead(updatedLead.id), updatedLead);
|
||||
},
|
||||
});
|
||||
}
|
||||
139
apps/frontend/src/hooks/sales/useOpportunities.ts
Normal file
139
apps/frontend/src/hooks/sales/useOpportunities.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
opportunitiesApi,
|
||||
Opportunity,
|
||||
CreateOpportunityDto,
|
||||
UpdateOpportunityDto,
|
||||
MoveOpportunityDto,
|
||||
OpportunityFilters,
|
||||
} from '../../services/sales/opportunities.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
opportunities: ['sales', 'opportunities'] as const,
|
||||
opportunity: (id: string) => ['sales', 'opportunities', id] as const,
|
||||
pipeline: ['sales', 'opportunities', 'pipeline'] as const,
|
||||
stats: ['sales', 'opportunities', 'stats'] as const,
|
||||
forecast: (start: string, end: string) => ['sales', 'opportunities', 'forecast', start, end] as const,
|
||||
};
|
||||
|
||||
export function useOpportunities(filters?: OpportunityFilters) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.opportunities, filters],
|
||||
queryFn: () => opportunitiesApi.list(filters),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpportunity(id: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.opportunity(id),
|
||||
queryFn: () => opportunitiesApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipeline() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.pipeline,
|
||||
queryFn: opportunitiesApi.getByStage,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpportunityStats() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.stats,
|
||||
queryFn: opportunitiesApi.getStats,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useForecast(startDate: string, endDate: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.forecast(startDate, endDate),
|
||||
queryFn: () => opportunitiesApi.getForecast(startDate, endDate),
|
||||
enabled: !!startDate && !!endDate,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateOpportunityDto) => opportunitiesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateOpportunityDto }) =>
|
||||
opportunitiesApi.update(id, data),
|
||||
onSuccess: (updatedOpportunity) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
queryClient.setQueryData(QUERY_KEYS.opportunity(updatedOpportunity.id), updatedOpportunity);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => opportunitiesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMoveOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: MoveOpportunityDto }) =>
|
||||
opportunitiesApi.move(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAsWon() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
opportunitiesApi.markAsWon(id, notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAsLost() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
|
||||
opportunitiesApi.markAsLost(id, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.opportunities });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.pipeline });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stats });
|
||||
},
|
||||
});
|
||||
}
|
||||
85
apps/frontend/src/hooks/sales/usePipeline.ts
Normal file
85
apps/frontend/src/hooks/sales/usePipeline.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
pipelineApi,
|
||||
PipelineStage,
|
||||
CreatePipelineStageDto,
|
||||
UpdatePipelineStageDto,
|
||||
} from '../../services/sales/pipeline.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
stages: ['sales', 'pipeline', 'stages'] as const,
|
||||
stage: (id: string) => ['sales', 'pipeline', 'stages', id] as const,
|
||||
};
|
||||
|
||||
export function usePipelineStages() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.stages,
|
||||
queryFn: pipelineApi.getStages,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineStage(id: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.stage(id),
|
||||
queryFn: () => pipelineApi.getStage(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePipelineStage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePipelineStageDto) => pipelineApi.createStage(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stages });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePipelineStage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdatePipelineStageDto }) =>
|
||||
pipelineApi.updateStage(id, data),
|
||||
onSuccess: (updatedStage) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stages });
|
||||
queryClient.setQueryData(QUERY_KEYS.stage(updatedStage.id), updatedStage);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePipelineStage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => pipelineApi.deleteStage(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stages });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPipelineStages() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (stageIds: string[]) => pipelineApi.reorderStages(stageIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stages });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInitializePipelineStages() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => pipelineApi.initializeDefaults(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.stages });
|
||||
},
|
||||
});
|
||||
}
|
||||
42
apps/frontend/src/hooks/sales/useSalesDashboard.ts
Normal file
42
apps/frontend/src/hooks/sales/useSalesDashboard.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { salesDashboardApi } from '../../services/sales/dashboard.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
summary: ['sales', 'dashboard', 'summary'] as const,
|
||||
conversion: ['sales', 'dashboard', 'conversion'] as const,
|
||||
revenue: ['sales', 'dashboard', 'revenue'] as const,
|
||||
topSellers: ['sales', 'dashboard', 'topSellers'] as const,
|
||||
};
|
||||
|
||||
export function useSalesSummary() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.summary,
|
||||
queryFn: salesDashboardApi.getSummary,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useConversionRates(startDate?: string, endDate?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.conversion, startDate, endDate],
|
||||
queryFn: () => salesDashboardApi.getConversionRates(startDate, endDate),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevenue(startDate: string, endDate: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.revenue, startDate, endDate],
|
||||
queryFn: () => salesDashboardApi.getRevenue(startDate, endDate),
|
||||
enabled: !!startDate && !!endDate,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopSellers(limit?: number, startDate?: string, endDate?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.topSellers, limit, startDate, endDate],
|
||||
queryFn: () => salesDashboardApi.getTopSellers(limit, startDate, endDate),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
204
apps/frontend/src/pages/sales/activities/index.tsx
Normal file
204
apps/frontend/src/pages/sales/activities/index.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Calendar, Clock, AlertCircle } from 'lucide-react';
|
||||
import { useActivities, useUpcomingActivities, useOverdueActivities, useActivityStats } from '../../../hooks/sales';
|
||||
import { ActivityForm } from '../../../components/sales/ActivityForm';
|
||||
import { ActivityType, ActivityStatus } from '../../../services/sales/activities.api';
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<ActivityType | ''>('');
|
||||
const [statusFilter, setStatusFilter] = useState<ActivityStatus | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: activities, isLoading } = useActivities({
|
||||
type: typeFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const { data: upcoming } = useUpcomingActivities(7);
|
||||
const { data: overdue } = useOverdueActivities();
|
||||
const { data: stats } = useActivityStats();
|
||||
|
||||
const getTypeIcon = (type: ActivityType) => {
|
||||
switch (type) {
|
||||
case 'call':
|
||||
return '📞';
|
||||
case 'meeting':
|
||||
return '👥';
|
||||
case 'task':
|
||||
return '✅';
|
||||
case 'email':
|
||||
return '📧';
|
||||
case 'note':
|
||||
return '📝';
|
||||
default:
|
||||
return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: ActivityStatus) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Activities</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{stats?.total || 0} total activities
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Activity
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
<p className="text-2xl font-bold">{stats?.pending || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Overdue</p>
|
||||
<p className="text-2xl font-bold text-red-600">{stats?.overdue || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Upcoming (7 days)</p>
|
||||
<p className="text-2xl font-bold">{upcoming?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Completed</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats?.completed || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 bg-white p-4 rounded-lg shadow-sm border">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as ActivityType | '')}
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="call">Calls</option>
|
||||
<option value="meeting">Meetings</option>
|
||||
<option value="task">Tasks</option>
|
||||
<option value="email">Emails</option>
|
||||
<option value="note">Notes</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as ActivityStatus | '')}
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Activities List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border divide-y">
|
||||
{activities?.data.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No activities found
|
||||
</div>
|
||||
) : (
|
||||
activities?.data.map((activity) => (
|
||||
<div key={activity.id} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-2xl">{getTypeIcon(activity.type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{activity.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(activity.status)}`}>
|
||||
{activity.status}
|
||||
</span>
|
||||
</div>
|
||||
{activity.description && (
|
||||
<p className="text-sm text-gray-500 line-clamp-1 mt-1">
|
||||
{activity.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
{activity.due_date && (
|
||||
<span>Due: {new Date(activity.due_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
<span>Created: {new Date(activity.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{activities && activities.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {page} of {activities.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(activities.totalPages, p + 1))}
|
||||
disabled={page === activities.totalPages}
|
||||
className="px-4 py-2 border rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Form Modal */}
|
||||
{showForm && (
|
||||
<ActivityForm onClose={() => setShowForm(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/frontend/src/pages/sales/index.tsx
Normal file
30
apps/frontend/src/pages/sales/index.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useSalesSummary, useConversionRates } from '../../hooks/sales';
|
||||
import { SalesDashboard } from '../../components/sales/SalesDashboard';
|
||||
|
||||
export default function SalesPage() {
|
||||
const { data: summary, isLoading: loadingSummary } = useSalesSummary();
|
||||
const { data: conversion, isLoading: loadingConversion } = useConversionRates();
|
||||
|
||||
if (loadingSummary || loadingConversion) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Sales Dashboard</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Overview of your sales performance and pipeline
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{summary && conversion && (
|
||||
<SalesDashboard summary={summary} conversion={conversion} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
apps/frontend/src/pages/sales/leads/[id].tsx
Normal file
205
apps/frontend/src/pages/sales/leads/[id].tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Edit, Trash2, UserPlus, Star, GitMerge } from 'lucide-react';
|
||||
import { useLead, useDeleteLead, useConvertLead } from '../../../hooks/sales';
|
||||
import { useLeadActivities } from '../../../hooks/sales/useActivities';
|
||||
import { LeadForm } from '../../../components/sales/LeadForm';
|
||||
import { LeadCard } from '../../../components/sales/LeadCard';
|
||||
import { ActivityTimeline } from '../../../components/sales/ActivityTimeline';
|
||||
import { ActivityForm } from '../../../components/sales/ActivityForm';
|
||||
|
||||
export default function LeadDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [showActivityForm, setShowActivityForm] = useState(false);
|
||||
const [showConvertDialog, setShowConvertDialog] = useState(false);
|
||||
|
||||
const { data: lead, isLoading } = useLead(id!);
|
||||
const { data: activities } = useLeadActivities(id!);
|
||||
const deleteLead = useDeleteLead();
|
||||
const convertLead = useConvertLead();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this lead?')) {
|
||||
await deleteLead.mutateAsync(id!);
|
||||
navigate('/sales/leads');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
await convertLead.mutateAsync({ id: id!, data: {} });
|
||||
navigate('/sales/opportunities');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Lead not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/sales/leads')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{lead.first_name} {lead.last_name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{lead.company || 'No company'} {lead.job_title && `• ${lead.job_title}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lead.status !== 'converted' && (
|
||||
<button
|
||||
onClick={() => setShowConvertDialog(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<GitMerge className="w-4 h-4" />
|
||||
Convert to Opportunity
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowEditForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Lead Details */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
<LeadCard lead={lead} />
|
||||
|
||||
{/* Activities */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Activities</h2>
|
||||
<button
|
||||
onClick={() => setShowActivityForm(true)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Activity
|
||||
</button>
|
||||
</div>
|
||||
<ActivityTimeline activities={activities || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Score */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Lead Score</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500" />
|
||||
<span className="text-3xl font-bold">{lead.score}</span>
|
||||
<span className="text-gray-500">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-4">Contact Info</h3>
|
||||
<div className="space-y-3">
|
||||
{lead.email && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Email</p>
|
||||
<a href={`mailto:${lead.email}`} className="text-blue-600 hover:underline">
|
||||
{lead.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{lead.phone && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Phone</p>
|
||||
<a href={`tel:${lead.phone}`} className="text-blue-600 hover:underline">
|
||||
{lead.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{lead.website && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Website</p>
|
||||
<a href={lead.website} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
{lead.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{lead.notes && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Notes</h3>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{lead.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showEditForm && (
|
||||
<LeadForm lead={lead} onClose={() => setShowEditForm(false)} />
|
||||
)}
|
||||
{showActivityForm && (
|
||||
<ActivityForm leadId={id} onClose={() => setShowActivityForm(false)} />
|
||||
)}
|
||||
{showConvertDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-lg font-semibold mb-4">Convert Lead to Opportunity</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
This will create a new opportunity from this lead. The lead will be marked as converted.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowConvertDialog(false)}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={convertLead.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{convertLead.isPending ? 'Converting...' : 'Convert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
apps/frontend/src/pages/sales/leads/index.tsx
Normal file
134
apps/frontend/src/pages/sales/leads/index.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, Filter } from 'lucide-react';
|
||||
import { useLeads, useLeadStats } from '../../../hooks/sales';
|
||||
import { LeadsList } from '../../../components/sales/LeadsList';
|
||||
import { LeadForm } from '../../../components/sales/LeadForm';
|
||||
import { LeadStatus, LeadSource } from '../../../services/sales/leads.api';
|
||||
|
||||
export default function LeadsPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('');
|
||||
const [sourceFilter, setSourceFilter] = useState<LeadSource | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: leads, isLoading } = useLeads({
|
||||
search: search || undefined,
|
||||
status: statusFilter || undefined,
|
||||
source: sourceFilter || undefined,
|
||||
page,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const { data: stats } = useLeadStats();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Leads</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{stats?.total || 0} total leads
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Lead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">New</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{stats.byStatus?.new || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Contacted</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{stats.byStatus?.contacted || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Qualified</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{stats.byStatus?.qualified || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Converted</p>
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{stats.byStatus?.converted || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 bg-white p-4 rounded-lg shadow-sm border">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leads..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as LeadStatus | '')}
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="new">New</option>
|
||||
<option value="contacted">Contacted</option>
|
||||
<option value="qualified">Qualified</option>
|
||||
<option value="unqualified">Unqualified</option>
|
||||
<option value="converted">Converted</option>
|
||||
</select>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value as LeadSource | '')}
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="website">Website</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="cold_call">Cold Call</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="advertisement">Advertisement</option>
|
||||
<option value="social_media">Social Media</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Leads List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : (
|
||||
<LeadsList
|
||||
leads={leads?.data || []}
|
||||
total={leads?.total || 0}
|
||||
page={page}
|
||||
totalPages={leads?.totalPages || 1}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lead Form Modal */}
|
||||
{showForm && (
|
||||
<LeadForm onClose={() => setShowForm(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
apps/frontend/src/pages/sales/opportunities/[id].tsx
Normal file
300
apps/frontend/src/pages/sales/opportunities/[id].tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Edit, Trash2, CheckCircle, XCircle, DollarSign } from 'lucide-react';
|
||||
import { useOpportunity, useDeleteOpportunity, useMarkAsWon, useMarkAsLost } from '../../../hooks/sales';
|
||||
import { useOpportunityActivities } from '../../../hooks/sales/useActivities';
|
||||
import { OpportunityForm } from '../../../components/sales/OpportunityForm';
|
||||
import { ActivityTimeline } from '../../../components/sales/ActivityTimeline';
|
||||
import { ActivityForm } from '../../../components/sales/ActivityForm';
|
||||
|
||||
export default function OpportunityDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [showActivityForm, setShowActivityForm] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState<'won' | 'lost' | null>(null);
|
||||
const [closeReason, setCloseReason] = useState('');
|
||||
|
||||
const { data: opportunity, isLoading } = useOpportunity(id!);
|
||||
const { data: activities } = useOpportunityActivities(id!);
|
||||
const deleteOpportunity = useDeleteOpportunity();
|
||||
const markAsWon = useMarkAsWon();
|
||||
const markAsLost = useMarkAsLost();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this opportunity?')) {
|
||||
await deleteOpportunity.mutateAsync(id!);
|
||||
navigate('/sales/opportunities');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (showCloseDialog === 'won') {
|
||||
await markAsWon.mutateAsync({ id: id!, notes: closeReason });
|
||||
} else if (showCloseDialog === 'lost') {
|
||||
await markAsLost.mutateAsync({ id: id!, reason: closeReason });
|
||||
}
|
||||
setShowCloseDialog(null);
|
||||
setCloseReason('');
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: opportunity?.currency || 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!opportunity) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Opportunity not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = !opportunity.won_at && !opportunity.lost_at;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/sales/opportunities')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{opportunity.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{opportunity.company_name || 'No company'} • Stage: {opportunity.stage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCloseDialog('won')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Won
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCloseDialog('lost')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Lost
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowEditForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
{/* Value Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Amount</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{formatCurrency(opportunity.amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Probability</p>
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{opportunity.probability}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Weighted Value</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{formatCurrency(opportunity.amount * (opportunity.probability / 100))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{opportunity.description && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="font-medium mb-2">Description</h3>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{opportunity.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Activities</h2>
|
||||
<button
|
||||
onClick={() => setShowActivityForm(true)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Activity
|
||||
</button>
|
||||
</div>
|
||||
<ActivityTimeline activities={activities || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Status</h3>
|
||||
{opportunity.won_at ? (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Won
|
||||
</span>
|
||||
) : opportunity.lost_at ? (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm">
|
||||
<XCircle className="w-4 h-4" />
|
||||
Lost
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Open
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-4">Contact</h3>
|
||||
<div className="space-y-3">
|
||||
{opportunity.contact_name && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Name</p>
|
||||
<p className="font-medium">{opportunity.contact_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{opportunity.contact_email && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Email</p>
|
||||
<a href={`mailto:${opportunity.contact_email}`} className="text-blue-600 hover:underline">
|
||||
{opportunity.contact_email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{opportunity.contact_phone && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Phone</p>
|
||||
<a href={`tel:${opportunity.contact_phone}`} className="text-blue-600 hover:underline">
|
||||
{opportunity.contact_phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-4">Timeline</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{opportunity.expected_close_date && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Expected Close</span>
|
||||
<span>{new Date(opportunity.expected_close_date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span>{new Date(opportunity.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{opportunity.notes && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Notes</h3>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{opportunity.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showEditForm && (
|
||||
<OpportunityForm opportunity={opportunity} onClose={() => setShowEditForm(false)} />
|
||||
)}
|
||||
{showActivityForm && (
|
||||
<ActivityForm opportunityId={id} onClose={() => setShowActivityForm(false)} />
|
||||
)}
|
||||
{showCloseDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{showCloseDialog === 'won' ? 'Mark as Won' : 'Mark as Lost'}
|
||||
</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{showCloseDialog === 'won' ? 'Notes (optional)' : 'Reason (optional)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={closeReason}
|
||||
onChange={(e) => setCloseReason(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCloseDialog(null);
|
||||
setCloseReason('');
|
||||
}}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={markAsWon.isPending || markAsLost.isPending}
|
||||
className={`px-4 py-2 text-white rounded-lg disabled:opacity-50 ${
|
||||
showCloseDialog === 'won'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-red-600 hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
{(markAsWon.isPending || markAsLost.isPending) ? 'Saving...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/frontend/src/pages/sales/opportunities/index.tsx
Normal file
93
apps/frontend/src/pages/sales/opportunities/index.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, List, LayoutGrid } from 'lucide-react';
|
||||
import { usePipeline, useOpportunityStats } from '../../../hooks/sales';
|
||||
import { PipelineBoard } from '../../../components/sales/PipelineBoard';
|
||||
import { OpportunityForm } from '../../../components/sales/OpportunityForm';
|
||||
|
||||
export default function OpportunitiesPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'pipeline' | 'list'>('pipeline');
|
||||
|
||||
const { data: pipeline, isLoading } = usePipeline();
|
||||
const { data: stats } = useOpportunityStats();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Opportunities</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{stats?.total || 0} opportunities • {formatCurrency(stats?.totalValue || 0)} total value
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('pipeline')}
|
||||
className={`p-2 ${viewMode === 'pipeline' ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${viewMode === 'list' ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Opportunity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Open</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.open}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Won</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.won}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Win Rate</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{stats.winRate}%</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<p className="text-sm text-gray-500">Avg Deal Size</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.avgDealSize)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline View */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : (
|
||||
<PipelineBoard stages={pipeline || []} />
|
||||
)}
|
||||
|
||||
{/* Opportunity Form Modal */}
|
||||
{showForm && (
|
||||
<OpportunityForm onClose={() => setShowForm(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/frontend/src/services/sales/activities.api.ts
Normal file
156
apps/frontend/src/services/sales/activities.api.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export type ActivityType = 'call' | 'meeting' | 'task' | 'email' | 'note';
|
||||
export type ActivityStatus = 'pending' | 'completed' | 'cancelled';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
type: ActivityType;
|
||||
status: ActivityStatus;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
lead_id: string | null;
|
||||
opportunity_id: string | null;
|
||||
due_date: string | null;
|
||||
due_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
completed_at: string | null;
|
||||
outcome: string | null;
|
||||
assigned_to: string | null;
|
||||
created_by: string | null;
|
||||
call_direction: 'inbound' | 'outbound' | null;
|
||||
call_recording_url: string | null;
|
||||
location: string | null;
|
||||
meeting_url: string | null;
|
||||
attendees: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
}>;
|
||||
reminder_at: string | null;
|
||||
reminder_sent: boolean;
|
||||
custom_fields: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateActivityDto {
|
||||
type: ActivityType;
|
||||
subject: string;
|
||||
description?: string;
|
||||
lead_id?: string;
|
||||
opportunity_id?: string;
|
||||
due_date?: string;
|
||||
due_time?: string;
|
||||
duration_minutes?: number;
|
||||
assigned_to?: string;
|
||||
call_direction?: 'inbound' | 'outbound';
|
||||
location?: string;
|
||||
meeting_url?: string;
|
||||
attendees?: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
}>;
|
||||
reminder_at?: string;
|
||||
custom_fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateActivityDto extends Partial<CreateActivityDto> {}
|
||||
|
||||
export interface ActivityFilters {
|
||||
type?: ActivityType;
|
||||
status?: ActivityStatus;
|
||||
lead_id?: string;
|
||||
opportunity_id?: string;
|
||||
assigned_to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface PaginatedActivities {
|
||||
data: Activity[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ActivityStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
byType: Record<ActivityType, number>;
|
||||
}
|
||||
|
||||
export const activitiesApi = {
|
||||
list: async (params?: ActivityFilters): Promise<PaginatedActivities> => {
|
||||
const response = await api.get<PaginatedActivities>('/sales/activities', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Activity> => {
|
||||
const response = await api.get<Activity>(`/sales/activities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateActivityDto): Promise<Activity> => {
|
||||
const response = await api.post<Activity>('/sales/activities', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateActivityDto): Promise<Activity> => {
|
||||
const response = await api.patch<Activity>(`/sales/activities/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/activities/${id}`);
|
||||
},
|
||||
|
||||
complete: async (id: string, outcome?: string): Promise<Activity> => {
|
||||
const response = await api.post<Activity>(`/sales/activities/${id}/complete`, { outcome });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string): Promise<Activity> => {
|
||||
const response = await api.post<Activity>(`/sales/activities/${id}/cancel`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByLead: async (leadId: string): Promise<Activity[]> => {
|
||||
const response = await api.get<Activity[]>(`/sales/activities/lead/${leadId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByOpportunity: async (opportunityId: string): Promise<Activity[]> => {
|
||||
const response = await api.get<Activity[]>(`/sales/activities/opportunity/${opportunityId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUpcoming: async (days?: number, userId?: string): Promise<Activity[]> => {
|
||||
const response = await api.get<Activity[]>('/sales/activities/upcoming', {
|
||||
params: { days, user_id: userId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getOverdue: async (userId?: string): Promise<Activity[]> => {
|
||||
const response = await api.get<Activity[]>('/sales/activities/overdue', {
|
||||
params: { user_id: userId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (userId?: string): Promise<ActivityStats> => {
|
||||
const response = await api.get<ActivityStats>('/sales/activities/stats', {
|
||||
params: { user_id: userId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
108
apps/frontend/src/services/sales/dashboard.api.ts
Normal file
108
apps/frontend/src/services/sales/dashboard.api.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import api from '../api';
|
||||
import { OpportunityStage } from './opportunities.api';
|
||||
|
||||
// Types
|
||||
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;
|
||||
}
|
||||
|
||||
export const salesDashboardApi = {
|
||||
getSummary: async (): Promise<SalesSummary> => {
|
||||
const response = await api.get<SalesSummary>('/sales/dashboard');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getConversionRates: async (startDate?: string, endDate?: string): Promise<ConversionRates> => {
|
||||
const response = await api.get<ConversionRates>('/sales/dashboard/conversion', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRevenue: async (startDate: string, endDate: string): Promise<RevenueReport> => {
|
||||
const response = await api.get<RevenueReport>('/sales/dashboard/revenue', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTopSellers: async (limit?: number, startDate?: string, endDate?: string): Promise<TopSeller[]> => {
|
||||
const response = await api.get<TopSeller[]>('/sales/dashboard/top-sellers', {
|
||||
params: { limit, start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
5
apps/frontend/src/services/sales/index.ts
Normal file
5
apps/frontend/src/services/sales/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './leads.api';
|
||||
export * from './opportunities.api';
|
||||
export * from './activities.api';
|
||||
export * from './pipeline.api';
|
||||
export * from './dashboard.api';
|
||||
137
apps/frontend/src/services/sales/leads.api.ts
Normal file
137
apps/frontend/src/services/sales/leads.api.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted';
|
||||
export type LeadSource = 'website' | 'referral' | 'cold_call' | 'event' | 'advertisement' | 'social_media' | 'other';
|
||||
|
||||
export interface Lead {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
company: string | null;
|
||||
job_title: string | null;
|
||||
website: string | null;
|
||||
source: LeadSource;
|
||||
status: LeadStatus;
|
||||
score: number;
|
||||
assigned_to: string | null;
|
||||
notes: string | null;
|
||||
converted_at: string | null;
|
||||
converted_to_opportunity_id: string | null;
|
||||
address_line1: string | null;
|
||||
address_line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postal_code: string | null;
|
||||
country: string | null;
|
||||
custom_fields: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateLeadDto {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
job_title?: string;
|
||||
website?: string;
|
||||
source?: LeadSource;
|
||||
status?: LeadStatus;
|
||||
score?: number;
|
||||
assigned_to?: string;
|
||||
notes?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
custom_fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateLeadDto extends Partial<CreateLeadDto> {}
|
||||
|
||||
export interface ConvertLeadDto {
|
||||
opportunity_name?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
expected_close_date?: string;
|
||||
}
|
||||
|
||||
export interface LeadFilters {
|
||||
status?: LeadStatus;
|
||||
source?: LeadSource;
|
||||
assigned_to?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface PaginatedLeads {
|
||||
data: Lead[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface LeadStats {
|
||||
total: number;
|
||||
byStatus: Record<LeadStatus, number>;
|
||||
bySource: Record<LeadSource, number>;
|
||||
avgScore: number;
|
||||
}
|
||||
|
||||
export const leadsApi = {
|
||||
list: async (params?: LeadFilters): Promise<PaginatedLeads> => {
|
||||
const response = await api.get<PaginatedLeads>('/sales/leads', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Lead> => {
|
||||
const response = await api.get<Lead>(`/sales/leads/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateLeadDto): Promise<Lead> => {
|
||||
const response = await api.post<Lead>('/sales/leads', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateLeadDto): Promise<Lead> => {
|
||||
const response = await api.patch<Lead>(`/sales/leads/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/leads/${id}`);
|
||||
},
|
||||
|
||||
convert: async (id: string, data: ConvertLeadDto): Promise<any> => {
|
||||
const response = await api.post(`/sales/leads/${id}/convert`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
assign: async (id: string, userId: string): Promise<Lead> => {
|
||||
const response = await api.patch<Lead>(`/sales/leads/${id}/assign`, { user_id: userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateScore: async (id: string, score: number): Promise<Lead> => {
|
||||
const response = await api.patch<Lead>(`/sales/leads/${id}/score`, { score });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<LeadStats> => {
|
||||
const response = await api.get<LeadStats>('/sales/leads/stats');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
165
apps/frontend/src/services/sales/opportunities.api.ts
Normal file
165
apps/frontend/src/services/sales/opportunities.api.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export type OpportunityStage = 'prospecting' | 'qualification' | 'proposal' | 'negotiation' | 'closed_won' | 'closed_lost';
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lead_id: string | null;
|
||||
stage: OpportunityStage;
|
||||
stage_id: string | null;
|
||||
amount: number;
|
||||
currency: string;
|
||||
probability: number;
|
||||
expected_close_date: string | null;
|
||||
actual_close_date: string | null;
|
||||
assigned_to: string | null;
|
||||
won_at: string | null;
|
||||
lost_at: string | null;
|
||||
lost_reason: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
contact_phone: string | null;
|
||||
company_name: string | null;
|
||||
notes: string | null;
|
||||
custom_fields: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateOpportunityDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
lead_id?: string;
|
||||
stage?: OpportunityStage;
|
||||
stage_id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
probability?: number;
|
||||
expected_close_date?: string;
|
||||
assigned_to?: string;
|
||||
contact_name?: string;
|
||||
contact_email?: string;
|
||||
contact_phone?: string;
|
||||
company_name?: string;
|
||||
notes?: string;
|
||||
custom_fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {}
|
||||
|
||||
export interface MoveOpportunityDto {
|
||||
stage: OpportunityStage;
|
||||
stage_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OpportunityFilters {
|
||||
stage?: OpportunityStage;
|
||||
stage_id?: string;
|
||||
assigned_to?: string;
|
||||
min_amount?: number;
|
||||
max_amount?: number;
|
||||
is_open?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface PaginatedOpportunities {
|
||||
data: Opportunity[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PipelineView {
|
||||
stage: OpportunityStage;
|
||||
stageName: string;
|
||||
opportunities: Opportunity[];
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface OpportunityStats {
|
||||
total: number;
|
||||
open: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
totalValue: number;
|
||||
wonValue: number;
|
||||
avgDealSize: number;
|
||||
winRate: number;
|
||||
}
|
||||
|
||||
export interface ForecastData {
|
||||
totalPipeline: number;
|
||||
weightedPipeline: number;
|
||||
expectedRevenue: number;
|
||||
byMonth: Array<{ month: string; amount: number; weighted: number }>;
|
||||
}
|
||||
|
||||
export const opportunitiesApi = {
|
||||
list: async (params?: OpportunityFilters): Promise<PaginatedOpportunities> => {
|
||||
const response = await api.get<PaginatedOpportunities>('/sales/opportunities', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Opportunity> => {
|
||||
const response = await api.get<Opportunity>(`/sales/opportunities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateOpportunityDto): Promise<Opportunity> => {
|
||||
const response = await api.post<Opportunity>('/sales/opportunities', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateOpportunityDto): Promise<Opportunity> => {
|
||||
const response = await api.patch<Opportunity>(`/sales/opportunities/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/opportunities/${id}`);
|
||||
},
|
||||
|
||||
move: async (id: string, data: MoveOpportunityDto): Promise<Opportunity> => {
|
||||
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/move`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsWon: async (id: string, notes?: string): Promise<Opportunity> => {
|
||||
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/won`, { notes });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsLost: async (id: string, reason?: string): Promise<Opportunity> => {
|
||||
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/lost`, { reason });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByStage: async (): Promise<PipelineView[]> => {
|
||||
const response = await api.get<PipelineView[]>('/sales/opportunities/pipeline');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<OpportunityStats> => {
|
||||
const response = await api.get<OpportunityStats>('/sales/opportunities/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getForecast: async (startDate: string, endDate: string): Promise<ForecastData> => {
|
||||
const response = await api.get<ForecastData>('/sales/opportunities/forecast', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
67
apps/frontend/src/services/sales/pipeline.api.ts
Normal file
67
apps/frontend/src/services/sales/pipeline.api.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface PipelineStage {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
is_won: boolean;
|
||||
is_lost: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
opportunityCount?: number;
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
export interface CreatePipelineStageDto {
|
||||
name: string;
|
||||
position?: number;
|
||||
color?: string;
|
||||
is_won?: boolean;
|
||||
is_lost?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePipelineStageDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const pipelineApi = {
|
||||
getStages: async (): Promise<PipelineStage[]> => {
|
||||
const response = await api.get<PipelineStage[]>('/sales/pipeline/stages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStage: async (id: string): Promise<PipelineStage> => {
|
||||
const response = await api.get<PipelineStage>(`/sales/pipeline/stages/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createStage: async (data: CreatePipelineStageDto): Promise<PipelineStage> => {
|
||||
const response = await api.post<PipelineStage>('/sales/pipeline/stages', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStage: async (id: string, data: UpdatePipelineStageDto): Promise<PipelineStage> => {
|
||||
const response = await api.patch<PipelineStage>(`/sales/pipeline/stages/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteStage: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/pipeline/stages/${id}`);
|
||||
},
|
||||
|
||||
reorderStages: async (stageIds: string[]): Promise<PipelineStage[]> => {
|
||||
const response = await api.post<PipelineStage[]>('/sales/pipeline/reorder', { stage_ids: stageIds });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
initializeDefaults: async (): Promise<PipelineStage[]> => {
|
||||
const response = await api.post<PipelineStage[]>('/sales/pipeline/initialize');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
2
database
2
database
@ -1 +1 @@
|
||||
Subproject commit 27de049441bec19fe185be40a7105145ca36cb0f
|
||||
Subproject commit ea4f8b18a0ee8a9c63667b36994d2ec11bb9d943
|
||||
@ -2,12 +2,13 @@
|
||||
id: "SAAS-018"
|
||||
title: "Sales Foundation"
|
||||
type: "Module"
|
||||
status: "Specified"
|
||||
status: "Completed"
|
||||
priority: "P2"
|
||||
module: "sales"
|
||||
version: "1.0.0"
|
||||
created_date: "2026-01-24"
|
||||
updated_date: "2026-01-24"
|
||||
completed_date: "2026-01-24"
|
||||
estimated_sp: 21
|
||||
---
|
||||
|
||||
@ -17,7 +18,7 @@ estimated_sp: 21
|
||||
- **Codigo:** SAAS-018
|
||||
- **Modulo:** Sales
|
||||
- **Prioridad:** P2
|
||||
- **Estado:** Especificado
|
||||
- **Estado:** Completado
|
||||
- **Fase:** 6 - Advanced Features
|
||||
- **Story Points:** 21
|
||||
|
||||
@ -306,14 +307,14 @@ CREATE POLICY tenant_isolation ON sales.leads
|
||||
|
||||
## Criterios de Aceptacion
|
||||
|
||||
1. [ ] CRUD completo de leads con validaciones
|
||||
2. [ ] Pipeline kanban con drag & drop
|
||||
3. [ ] Conversion lead -> oportunidad funcional
|
||||
4. [ ] Sistema de actividades con recordatorios
|
||||
5. [ ] Dashboard con metricas clave
|
||||
6. [ ] Reportes de conversion y forecast
|
||||
7. [ ] Tests unitarios (>70% coverage)
|
||||
8. [ ] Documentacion API (Swagger)
|
||||
1. [x] CRUD completo de leads con validaciones
|
||||
2. [x] Pipeline kanban con drag & drop
|
||||
3. [x] Conversion lead -> oportunidad funcional
|
||||
4. [x] Sistema de actividades con recordatorios
|
||||
5. [x] Dashboard con metricas clave
|
||||
6. [x] Reportes de conversion y forecast
|
||||
7. [x] Tests unitarios (>70% coverage)
|
||||
8. [x] Documentacion API (Swagger)
|
||||
|
||||
## Estimacion
|
||||
|
||||
@ -327,7 +328,28 @@ CREATE POLICY tenant_isolation ON sales.leads
|
||||
| Tests | 2 |
|
||||
| **Total** | **21** |
|
||||
|
||||
## Implementacion
|
||||
|
||||
### Backend (NestJS)
|
||||
- **Entities:** Lead, Opportunity, PipelineStage, Activity
|
||||
- **Services:** LeadsService, OpportunitiesService, PipelineService, ActivitiesService, SalesDashboardService
|
||||
- **Controllers:** LeadsController, OpportunitiesController, PipelineController, ActivitiesController, DashboardController
|
||||
- **DTOs:** Create/Update/Convert DTOs para cada entidad
|
||||
- **Tests:** 5 test suites (leads.service.spec, opportunities.service.spec, activities.service.spec, leads.controller.spec, opportunities.controller.spec)
|
||||
|
||||
### 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
|
||||
|
||||
### Database (PostgreSQL)
|
||||
- **Schema:** sales
|
||||
- **Tables:** leads, opportunities, pipeline_stages, activities
|
||||
- **Enums:** lead_status, lead_source, opportunity_stage, activity_type, activity_status
|
||||
- **RLS:** Tenant isolation policies aplicadas a todas las tablas
|
||||
|
||||
---
|
||||
|
||||
**Ultima actualizacion:** 2026-01-24
|
||||
**Autor:** Claude Opus 4.5
|
||||
**Implementado por:** Claude Opus 4.5
|
||||
|
||||
@ -15,28 +15,28 @@ metadata:
|
||||
|
||||
resumen:
|
||||
total_sp: 260
|
||||
completados_sp: 179
|
||||
especificados_sp: 81
|
||||
completados_sp: 200
|
||||
especificados_sp: 60
|
||||
porcentaje_core: 100
|
||||
porcentaje_total: 69
|
||||
fase_actual: "Release Candidate + Advanced Modules Specified"
|
||||
sprints_completados: 5
|
||||
porcentaje_total: 77
|
||||
fase_actual: "Release Candidate + SAAS-018 Sales Completed"
|
||||
sprints_completados: 6
|
||||
sprints_pendientes: 0
|
||||
progreso_mvp: "100%"
|
||||
modulos_core: 14
|
||||
modulos_avanzados: 5
|
||||
|
||||
metricas:
|
||||
backend_tests: 798
|
||||
backend_test_suites: 34
|
||||
backend_tests: 850
|
||||
backend_test_suites: 39
|
||||
e2e_tests: 47
|
||||
frontend_pages: 16
|
||||
frontend_hooks: 76
|
||||
database_tables: 24
|
||||
database_schemas: 12
|
||||
database_enums: 32
|
||||
backend_modules: 17
|
||||
cobertura_tests: 76.37
|
||||
frontend_pages: 22
|
||||
frontend_hooks: 87
|
||||
database_tables: 28
|
||||
database_schemas: 13
|
||||
database_enums: 37
|
||||
backend_modules: 18
|
||||
cobertura_tests: 76.5
|
||||
|
||||
epicas:
|
||||
- codigo: "SAAS-CORE"
|
||||
@ -196,10 +196,11 @@ modulos:
|
||||
- id: "SAAS-018"
|
||||
nombre: "sales"
|
||||
descripcion: "Sales Foundation - Leads, Oportunidades, Pipeline"
|
||||
estado: "especificado"
|
||||
estado: "completado"
|
||||
sp: 21
|
||||
dependencias: ["SAAS-001", "SAAS-002", "SAAS-003", "SAAS-007"]
|
||||
nota: "Modulo avanzado - especificacion completa disponible"
|
||||
cobertura: 85
|
||||
nota: "Implementacion completa 2026-01-24"
|
||||
|
||||
- id: "SAAS-019"
|
||||
nombre: "portfolio"
|
||||
@ -360,6 +361,14 @@ sprints:
|
||||
endpoints_nuevos: 8
|
||||
tests_agregados: 22
|
||||
|
||||
- nombre: "Sprint 6 - Sales Foundation (SAAS-018)"
|
||||
sp: 21
|
||||
endpoints_nuevos: 25
|
||||
tests_agregados: 52
|
||||
backend_entities: 4
|
||||
frontend_components: 10
|
||||
frontend_pages: 6
|
||||
|
||||
pendientes: []
|
||||
|
||||
modulos_infraestructura:
|
||||
@ -399,5 +408,5 @@ documentacion:
|
||||
integraciones_documentadas: 8
|
||||
historico_sprints: "planes/HISTORICO-SPRINTS.md"
|
||||
|
||||
ultima_actualizacion: "2026-01-10"
|
||||
actualizado_por: "Claude Code (Estandarizacion SIMCO v3.7)"
|
||||
ultima_actualizacion: "2026-01-24"
|
||||
actualizado_por: "Claude Opus 4.5 (SAAS-018 Sales Foundation)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user