feat: Complete Z.CRM system with all 6 modules
✨ Features: - Complete authentication system with JWT - Dashboard with all 6 modules visible - Contact Management module (Salesforce-style) - CRM & Sales Pipeline module (Pipedrive-style) - Inventory & Assets module (SAP-style) - Tasks & Projects module (Jira/Asana-style) - HR Management module (BambooHR-style) - Marketing Management module (HubSpot-style) - Admin Panel with user management and role matrix - World-class UI/UX with RTL Arabic support - Cairo font (headings) + Readex Pro font (body) - Sample data for all modules - Protected routes and authentication flow - Backend API with Prisma + PostgreSQL - Comprehensive documentation 🎨 Design: - Color-coded modules - Professional data tables - Stats cards with metrics - Progress bars and status badges - Search and filters - Responsive layout 📊 Tech Stack: - Frontend: Next.js 14, TypeScript, Tailwind CSS - Backend: Node.js, Express, Prisma - Database: PostgreSQL - Auth: JWT with bcrypt 🚀 Production-ready frontend with all features accessible
This commit is contained in:
398
backend/src/modules/crm/deals.service.ts
Normal file
398
backend/src/modules/crm/deals.service.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
interface CreateDealData {
|
||||
name: string;
|
||||
contactId: string;
|
||||
structure: string; // B2B, B2C, B2G, PARTNERSHIP
|
||||
pipelineId: string;
|
||||
stage: string;
|
||||
estimatedValue: number;
|
||||
probability?: number;
|
||||
expectedCloseDate?: Date;
|
||||
ownerId: string;
|
||||
fiscalYear: number;
|
||||
}
|
||||
|
||||
interface UpdateDealData extends Partial<CreateDealData> {
|
||||
stage?: string;
|
||||
actualValue?: number;
|
||||
actualCloseDate?: Date;
|
||||
wonReason?: string;
|
||||
lostReason?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
class DealsService {
|
||||
async create(data: CreateDealData, userId: string) {
|
||||
// Generate deal number
|
||||
const dealNumber = await this.generateDealNumber();
|
||||
|
||||
const deal = await prisma.deal.create({
|
||||
data: {
|
||||
dealNumber,
|
||||
name: data.name,
|
||||
contactId: data.contactId,
|
||||
structure: data.structure,
|
||||
pipelineId: data.pipelineId,
|
||||
stage: data.stage,
|
||||
estimatedValue: data.estimatedValue,
|
||||
probability: data.probability,
|
||||
expectedCloseDate: data.expectedCloseDate,
|
||||
ownerId: data.ownerId,
|
||||
fiscalYear: data.fiscalYear,
|
||||
currency: 'SAR',
|
||||
},
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async findAll(filters: any, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.DealWhereInput = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ dealNumber: { contains: filters.search } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.structure) {
|
||||
where.structure = filters.structure;
|
||||
}
|
||||
|
||||
if (filters.stage) {
|
||||
where.stage = filters.stage;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters.ownerId) {
|
||||
where.ownerId = filters.ownerId;
|
||||
}
|
||||
|
||||
if (filters.fiscalYear) {
|
||||
where.fiscalYear = parseInt(filters.fiscalYear);
|
||||
}
|
||||
|
||||
const total = await prisma.deal.count({ where });
|
||||
|
||||
const deals = await prisma.deal.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deals,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const deal = await prisma.deal.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
contact: {
|
||||
include: {
|
||||
categories: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
employee: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
position: true,
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
quotes: {
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
},
|
||||
costSheets: {
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
},
|
||||
activities: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 20,
|
||||
},
|
||||
notes: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: {
|
||||
uploadedAt: 'desc',
|
||||
},
|
||||
},
|
||||
contracts: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deal) {
|
||||
throw new AppError(404, 'الصفقة غير موجودة - Deal not found');
|
||||
}
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateDealData, userId: string) {
|
||||
const existing = await prisma.deal.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'الصفقة غير موجودة - Deal not found');
|
||||
}
|
||||
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
contactId: data.contactId,
|
||||
stage: data.stage,
|
||||
estimatedValue: data.estimatedValue,
|
||||
actualValue: data.actualValue,
|
||||
probability: data.probability,
|
||||
expectedCloseDate: data.expectedCloseDate,
|
||||
actualCloseDate: data.actualCloseDate,
|
||||
wonReason: data.wonReason,
|
||||
lostReason: data.lostReason,
|
||||
status: data.status,
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
pipeline: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: {
|
||||
before: existing,
|
||||
after: deal,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async updateStage(id: string, stage: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: { stage },
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'STAGE_CHANGE',
|
||||
userId,
|
||||
changes: { stage },
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: deal.ownerId,
|
||||
type: 'DEAL_STAGE_CHANGED',
|
||||
title: 'تغيير مرحلة الصفقة - Deal stage changed',
|
||||
message: `تم تغيير مرحلة الصفقة "${deal.name}" إلى "${stage}"`,
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async win(id: string, actualValue: number, wonReason: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'WON',
|
||||
stage: 'WON',
|
||||
actualValue,
|
||||
wonReason,
|
||||
actualCloseDate: new Date(),
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'WIN',
|
||||
userId,
|
||||
changes: {
|
||||
status: 'WON',
|
||||
actualValue,
|
||||
wonReason,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: deal.ownerId,
|
||||
type: 'DEAL_WON',
|
||||
title: '🎉 صفقة رابحة - Deal Won!',
|
||||
message: `تم الفوز بالصفقة "${deal.name}" بقيمة ${actualValue} ريال`,
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async lose(id: string, lostReason: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'LOST',
|
||||
stage: 'LOST',
|
||||
lostReason,
|
||||
actualCloseDate: new Date(),
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'LOSE',
|
||||
userId,
|
||||
changes: {
|
||||
status: 'LOST',
|
||||
lostReason,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('DEAL', id);
|
||||
}
|
||||
|
||||
private async generateDealNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `DEAL-${year}-`;
|
||||
|
||||
const lastDeal = await prisma.deal.findFirst({
|
||||
where: {
|
||||
dealNumber: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
dealNumber: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastDeal) {
|
||||
const lastNumber = parseInt(lastDeal.dealNumber.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const dealsService = new DealsService();
|
||||
|
||||
Reference in New Issue
Block a user