✨ 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
399 lines
8.5 KiB
TypeScript
399 lines
8.5 KiB
TypeScript
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();
|
|
|