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:
383
backend/src/modules/hr/hr.service.ts
Normal file
383
backend/src/modules/hr/hr.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
class HRService {
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
async createEmployee(data: any, userId: string) {
|
||||
const uniqueEmployeeId = await this.generateEmployeeId();
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId,
|
||||
...data,
|
||||
},
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async findAllEmployees(filters: any, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ uniqueEmployeeId: { contains: filters.search } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.departmentId) {
|
||||
where.departmentId = filters.departmentId;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
const total = await prisma.employee.count({ where });
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
reportingTo: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
hireDate: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return { employees, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findEmployeeById(id: string) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
department: true,
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
reportingTo: true,
|
||||
directReports: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
attendances: {
|
||||
take: 30,
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
},
|
||||
leaves: {
|
||||
take: 10,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
salaries: {
|
||||
take: 12,
|
||||
orderBy: {
|
||||
year: 'desc',
|
||||
month: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async updateEmployee(id: string, data: any, userId: string) {
|
||||
const existing = await prisma.employee.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: {
|
||||
before: existing,
|
||||
after: employee,
|
||||
},
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async terminateEmployee(id: string, terminationDate: Date, reason: string, userId: string) {
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'TERMINATED',
|
||||
terminationDate,
|
||||
terminationReason: reason,
|
||||
},
|
||||
});
|
||||
|
||||
// Disable user account
|
||||
if (employee.id) {
|
||||
await prisma.user.updateMany({
|
||||
where: { employeeId: employee.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'TERMINATE',
|
||||
userId,
|
||||
reason,
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
// ========== ATTENDANCE ==========
|
||||
|
||||
async recordAttendance(data: any, userId: string) {
|
||||
const attendance = await prisma.attendance.create({
|
||||
data,
|
||||
});
|
||||
|
||||
return attendance;
|
||||
}
|
||||
|
||||
async getAttendance(employeeId: string, month: number, year: number) {
|
||||
return prisma.attendance.findMany({
|
||||
where: {
|
||||
employeeId,
|
||||
date: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(data: any, userId: string) {
|
||||
const leave = await prisma.leave.create({
|
||||
data: {
|
||||
...data,
|
||||
days: this.calculateLeaveDays(data.startDate, data.endDate),
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return leave;
|
||||
}
|
||||
|
||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||
const leave = await prisma.leave.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
action: 'APPROVE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return leave;
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(employeeId: string, month: number, year: number, userId: string) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
include: {
|
||||
allowances: {
|
||||
where: {
|
||||
OR: [
|
||||
{ isRecurring: true },
|
||||
{
|
||||
startDate: {
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
OR: [
|
||||
{ endDate: null },
|
||||
{
|
||||
endDate: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
commissions: {
|
||||
where: {
|
||||
month,
|
||||
year,
|
||||
status: 'APPROVED',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const basicSalary = employee.basicSalary;
|
||||
const allowances = employee.allowances.reduce((sum, a) => sum + Number(a.amount), 0);
|
||||
const commissions = employee.commissions.reduce((sum, c) => sum + Number(c.amount), 0);
|
||||
|
||||
// Calculate overtime from attendance
|
||||
const attendance = await prisma.attendance.findMany({
|
||||
where: {
|
||||
employeeId,
|
||||
date: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const overtimeHours = attendance.reduce((sum, a) => sum + Number(a.overtimeHours || 0), 0);
|
||||
const overtimePay = overtimeHours * 50; // SAR 50 per hour
|
||||
|
||||
const deductions = 0; // Calculate based on business rules
|
||||
|
||||
const netSalary = Number(basicSalary) + allowances + commissions + overtimePay - deductions;
|
||||
|
||||
const salary = await prisma.salary.create({
|
||||
data: {
|
||||
employeeId,
|
||||
month,
|
||||
year,
|
||||
basicSalary,
|
||||
allowances,
|
||||
deductions,
|
||||
commissions,
|
||||
overtimePay,
|
||||
netSalary,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'SALARY',
|
||||
entityId: salary.id,
|
||||
action: 'PROCESS',
|
||||
userId,
|
||||
});
|
||||
|
||||
return salary;
|
||||
}
|
||||
|
||||
// ========== HELPERS ==========
|
||||
|
||||
private async generateEmployeeId(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `EMP-${year}-`;
|
||||
|
||||
const lastEmployee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
uniqueEmployeeId: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastEmployee) {
|
||||
const lastNumber = parseInt(lastEmployee.uniqueEmployeeId.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private calculateLeaveDays(startDate: Date, endDate: Date): number {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays + 1;
|
||||
}
|
||||
}
|
||||
|
||||
export const hrService = new HRService();
|
||||
|
||||
Reference in New Issue
Block a user