## 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>
236 lines
6.4 KiB
TypeScript
236 lines
6.4 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|