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:
Talal Sharabi
2026-01-06 18:43:43 +04:00
commit 35daa52767
82 changed files with 29445 additions and 0 deletions

View File

@@ -0,0 +1,546 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { Prisma } from '@prisma/client';
interface CreateContactData {
type: string;
name: string;
nameAr?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
companyName?: string;
companyNameAr?: string;
taxNumber?: string;
commercialRegister?: string;
address?: string;
city?: string;
country?: string;
postalCode?: string;
categories?: string[];
tags?: string[];
parentId?: string;
source: string;
customFields?: any;
createdById: string;
}
interface UpdateContactData extends Partial<CreateContactData> {
status?: string;
rating?: number;
}
interface SearchFilters {
search?: string;
type?: string;
status?: string;
category?: string;
source?: string;
rating?: number;
createdFrom?: Date;
createdTo?: Date;
}
class ContactsService {
async create(data: CreateContactData, userId: string) {
// Check for duplicates based on email, phone, or tax number
await this.checkDuplicates(data);
// Generate unique contact ID
const uniqueContactId = await this.generateUniqueContactId();
// Create contact
const contact = await prisma.contact.create({
data: {
uniqueContactId,
type: data.type,
name: data.name,
nameAr: data.nameAr,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
connect: data.categories.map(id => ({ id }))
} : undefined,
tags: data.tags || [],
parentId: data.parentId,
source: data.source,
customFields: data.customFields || {},
createdById: data.createdById,
},
include: {
categories: true,
parent: true,
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'CREATE',
userId,
});
return contact;
}
async findAll(filters: SearchFilters, page: number = 1, pageSize: number = 20) {
const skip = (page - 1) * pageSize;
// Build where clause
const where: Prisma.ContactWhereInput = {
archivedAt: null, // Don't show archived contacts
};
if (filters.search) {
where.OR = [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
{ email: { contains: filters.search, mode: 'insensitive' } },
{ phone: { contains: filters.search } },
{ mobile: { contains: filters.search } },
{ companyName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.type) {
where.type = filters.type;
}
if (filters.status) {
where.status = filters.status;
}
if (filters.source) {
where.source = filters.source;
}
if (filters.rating !== undefined) {
where.rating = filters.rating;
}
if (filters.createdFrom || filters.createdTo) {
where.createdAt = {};
if (filters.createdFrom) {
where.createdAt.gte = filters.createdFrom;
}
if (filters.createdTo) {
where.createdAt.lte = filters.createdTo;
}
}
// Get total count
const total = await prisma.contact.count({ where });
// Get contacts
const contacts = await prisma.contact.findMany({
where,
skip,
take: pageSize,
include: {
categories: true,
parent: {
select: {
id: true,
name: true,
type: true,
},
},
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return {
contacts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findById(id: string) {
const contact = await prisma.contact.findUnique({
where: { id },
include: {
categories: true,
parent: true,
children: true,
relationships: {
include: {
toContact: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
relatedTo: {
include: {
fromContact: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
activities: {
take: 20,
orderBy: {
createdAt: 'desc',
},
},
deals: {
take: 10,
orderBy: {
createdAt: 'desc',
},
},
notes: {
orderBy: {
createdAt: 'desc',
},
},
attachments: {
orderBy: {
uploadedAt: 'desc',
},
},
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
});
if (!contact) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
return contact;
}
async update(id: string, data: UpdateContactData, userId: string) {
// Get existing contact
const existing = await prisma.contact.findUnique({
where: { id },
});
if (!existing) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
// Check for duplicates if email/phone/tax changed
if (data.email || data.phone || data.taxNumber) {
await this.checkDuplicates(data as CreateContactData, id);
}
// Update contact
const contact = await prisma.contact.update({
where: { id },
data: {
name: data.name,
nameAr: data.nameAr,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
set: data.categories.map(id => ({ id }))
} : undefined,
tags: data.tags,
source: data.source,
status: data.status,
rating: data.rating,
customFields: data.customFields,
},
include: {
categories: true,
parent: true,
},
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'UPDATE',
userId,
changes: {
before: existing,
after: contact,
},
});
return contact;
}
async archive(id: string, userId: string, reason?: string) {
const contact = await prisma.contact.update({
where: { id },
data: {
status: 'ARCHIVED',
archivedAt: new Date(),
},
});
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'ARCHIVE',
userId,
reason,
});
return contact;
}
async delete(id: string, userId: string, reason: string) {
// Hard delete - only for authorized users
// This should be restricted at the controller level
const contact = await prisma.contact.delete({
where: { id },
});
await AuditLogger.log({
entityType: 'CONTACT',
entityId: id,
action: 'DELETE',
userId,
reason,
});
return contact;
}
async merge(sourceId: string, targetId: string, userId: string, reason: string) {
// Get both contacts
const source = await prisma.contact.findUnique({ where: { id: sourceId } });
const target = await prisma.contact.findUnique({ where: { id: targetId } });
if (!source || !target) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
// Start transaction
await prisma.$transaction(async (tx) => {
// Update all related records to point to target
await tx.deal.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.activity.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.note.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.attachment.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
// Archive source contact
await tx.contact.update({
where: { id: sourceId },
data: {
status: 'ARCHIVED',
archivedAt: new Date(),
},
});
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: targetId,
action: 'MERGE',
userId,
reason,
changes: {
sourceId,
targetId,
sourceData: source,
},
});
return target;
}
async addRelationship(
fromContactId: string,
toContactId: string,
type: string,
startDate: Date,
userId: string
) {
const relationship = await prisma.contactRelationship.create({
data: {
fromContactId,
toContactId,
type,
startDate,
},
include: {
fromContact: {
select: {
id: true,
name: true,
},
},
toContact: {
select: {
id: true,
name: true,
},
},
},
});
await AuditLogger.log({
entityType: 'CONTACT_RELATIONSHIP',
entityId: relationship.id,
action: 'CREATE',
userId,
});
return relationship;
}
async getHistory(id: string) {
return AuditLogger.getEntityHistory('CONTACT', id);
}
// Private helper methods
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
const conditions: Prisma.ContactWhereInput[] = [];
if (data.email) {
conditions.push({ email: data.email });
}
if (data.phone) {
conditions.push({ phone: data.phone });
}
if (data.mobile) {
conditions.push({ mobile: data.mobile });
}
if (data.taxNumber) {
conditions.push({ taxNumber: data.taxNumber });
}
if (data.commercialRegister) {
conditions.push({ commercialRegister: data.commercialRegister });
}
if (conditions.length === 0) return;
const where: Prisma.ContactWhereInput = {
OR: conditions,
};
if (excludeId) {
where.NOT = { id: excludeId };
}
const duplicate = await prisma.contact.findFirst({
where,
select: {
id: true,
name: true,
email: true,
phone: true,
mobile: true,
},
});
if (duplicate) {
throw new AppError(
409,
`جهة اتصال مكررة - Duplicate contact found: ${duplicate.name}`
);
}
}
private async generateUniqueContactId(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CNT-${year}-`;
// Get the last contact for this year
const lastContact = await prisma.contact.findFirst({
where: {
uniqueContactId: {
startsWith: prefix,
},
},
orderBy: {
createdAt: 'desc',
},
select: {
uniqueContactId: true,
},
});
let nextNumber = 1;
if (lastContact) {
const lastNumber = parseInt(lastContact.uniqueContactId.split('-')[2]);
nextNumber = lastNumber + 1;
}
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
}
}
export const contactsService = new ContactsService();