update HR modules
This commit is contained in:
@@ -14,6 +14,46 @@ router.post('/portal/loans', portalController.submitLoanRequest);
|
||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-leaves',
|
||||
authorize('department_leave_requests', '*', 'read'),
|
||||
portalController.getManagedLeaves
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-leaves/:id/approve',
|
||||
authorize('department_leave_requests', '*', 'approve'),
|
||||
portalController.approveManagedLeave
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-leaves/:id/reject',
|
||||
authorize('department_leave_requests', '*', 'approve'),
|
||||
portalController.rejectManagedLeave
|
||||
);
|
||||
|
||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-overtime-requests',
|
||||
authorize('department_overtime_requests', '*', 'view'),
|
||||
portalController.getManagedOvertimeRequests
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-overtime-requests/:attendanceId/approve',
|
||||
authorize('department_overtime_requests', '*', 'approve'),
|
||||
portalController.approveManagedOvertimeRequest
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-overtime-requests/:attendanceId/reject',
|
||||
authorize('department_overtime_requests', '*', 'approve'),
|
||||
portalController.rejectManagedOvertimeRequest
|
||||
);
|
||||
|
||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||
@@ -86,5 +126,4 @@ router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEm
|
||||
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
|
||||
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
||||
|
||||
export default router;
|
||||
|
||||
export default router;
|
||||
@@ -313,40 +313,54 @@ class HRService {
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(data: any, userId: string) {
|
||||
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
||||
const startDate = new Date(data.startDate);
|
||||
const year = startDate.getFullYear();
|
||||
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||
|
||||
const ent = await prisma.leaveEntitlement.findUnique({
|
||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
|
||||
});
|
||||
if (ent) {
|
||||
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||
if (days > available) {
|
||||
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
||||
}
|
||||
}
|
||||
|
||||
const leave = await prisma.leave.create({
|
||||
data: {
|
||||
...data,
|
||||
days,
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return leave;
|
||||
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
|
||||
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||
}
|
||||
|
||||
const normalizedLeaveType = String(data.leaveType).toUpperCase();
|
||||
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
||||
const startDate = new Date(data.startDate);
|
||||
const year = startDate.getFullYear();
|
||||
|
||||
const ent = await prisma.leaveEntitlement.findUnique({
|
||||
where: {
|
||||
employeeId_year_leaveType: {
|
||||
employeeId: data.employeeId,
|
||||
year,
|
||||
leaveType: normalizedLeaveType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (ent) {
|
||||
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||
if (days > available) {
|
||||
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
||||
}
|
||||
}
|
||||
|
||||
const leave = await prisma.leave.create({
|
||||
data: {
|
||||
...data,
|
||||
leaveType: normalizedLeaveType,
|
||||
days,
|
||||
},
|
||||
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 },
|
||||
@@ -418,6 +432,195 @@ class HRService {
|
||||
return { leaves, total, page, pageSize };
|
||||
}
|
||||
|
||||
|
||||
private calculateLeaveHours(startDate: Date, endDate: Date) {
|
||||
const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
return diffHours > 0 ? diffHours : 0;
|
||||
}
|
||||
|
||||
private async canManagerApproveLeave(leave: any) {
|
||||
if (leave.leaveType === 'ANNUAL') {
|
||||
const yearStart = new Date(new Date(leave.startDate).getFullYear(), 0, 1);
|
||||
const yearEnd = new Date(new Date(leave.startDate).getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
|
||||
const approvedAnnualLeaves = await prisma.leave.findMany({
|
||||
where: {
|
||||
employeeId: leave.employeeId,
|
||||
leaveType: 'ANNUAL',
|
||||
status: 'APPROVED',
|
||||
startDate: {
|
||||
gte: yearStart,
|
||||
lte: yearEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
days: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedDays = approvedAnnualLeaves.reduce((sum, item) => sum + Number(item.days || 0), 0);
|
||||
return usedDays + Number(leave.days || 0) <= 12;
|
||||
}
|
||||
|
||||
if (leave.leaveType === 'HOURLY') {
|
||||
const start = new Date(leave.startDate);
|
||||
const monthStart = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const monthEnd = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
const approvedHourlyLeaves = await prisma.leave.findMany({
|
||||
where: {
|
||||
employeeId: leave.employeeId,
|
||||
leaveType: 'HOURLY',
|
||||
status: 'APPROVED',
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lte: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedHours = approvedHourlyLeaves.reduce((sum, item) => {
|
||||
return sum + this.calculateLeaveHours(item.startDate, item.endDate);
|
||||
}, 0);
|
||||
|
||||
const requestedHours = this.calculateLeaveHours(leave.startDate, leave.endDate);
|
||||
return usedHours + requestedHours <= 3;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async findManagedLeaves(status?: string) {
|
||||
const where: any = {};
|
||||
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
return prisma.leave.findMany({
|
||||
where,
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async managerApproveLeave(id: string, userId: string) {
|
||||
const leave = await prisma.leave.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!leave) {
|
||||
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||
}
|
||||
|
||||
if (leave.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن اعتماد طلب غير معلق - Only pending leave can be approved');
|
||||
}
|
||||
|
||||
const canApprove = await this.canManagerApproveLeave(leave);
|
||||
if (!canApprove) {
|
||||
throw new AppError(403, 'الطلب يتجاوز صلاحية مدير القسم ويحتاج موافقة HR - This leave exceeds manager approval limits and requires HR approval');
|
||||
}
|
||||
|
||||
const updated = await prisma.leave.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy: userId,
|
||||
approvedAt: new Date(),
|
||||
rejectedReason: null,
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
const year = new Date(updated.startDate).getFullYear();
|
||||
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: updated.id,
|
||||
action: 'MANAGER_APPROVE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async managerRejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||
const leave = await prisma.leave.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!leave) {
|
||||
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||
}
|
||||
|
||||
if (leave.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن رفض طلب غير معلق - Only pending leave can be rejected');
|
||||
}
|
||||
|
||||
const updated = await prisma.leave.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectedReason,
|
||||
approvedBy: null,
|
||||
approvedAt: null,
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: updated.id,
|
||||
action: 'MANAGER_REJECT',
|
||||
userId,
|
||||
reason: rejectedReason,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
||||
const ent = await prisma.leaveEntitlement.findUnique({
|
||||
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||
@@ -534,94 +737,260 @@ class HRService {
|
||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
const [total, loans] = await Promise.all([
|
||||
prisma.loan.count({ where }),
|
||||
prisma.loan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { loans, total, page, pageSize };
|
||||
}
|
||||
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
|
||||
const [total, loans] = await Promise.all([
|
||||
prisma.loan.count({ where }),
|
||||
prisma.loan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
basicSalary: true,
|
||||
position: {
|
||||
select: {
|
||||
code: true,
|
||||
title: true,
|
||||
titleAr: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installmentsList: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { loans, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findLoanById(id: string) {
|
||||
const loan = await prisma.loan.findUnique({
|
||||
where: { id },
|
||||
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
|
||||
});
|
||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
return loan;
|
||||
}
|
||||
const loan = await prisma.loan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
basicSalary: true,
|
||||
position: {
|
||||
select: {
|
||||
code: true,
|
||||
title: true,
|
||||
titleAr: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installmentsList: { orderBy: { installmentNumber: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
return loan;
|
||||
}
|
||||
|
||||
private isSystemAdminUser(user: any) {
|
||||
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
|
||||
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
|
||||
const positionTitleAr = user?.employee?.position?.titleAr || '';
|
||||
|
||||
return (
|
||||
positionCode === 'SYS_ADMIN' ||
|
||||
positionCode === 'SYSTEM_ADMIN' ||
|
||||
positionTitle === 'SYSTEM ADMINISTRATOR' ||
|
||||
positionTitleAr === 'مدير النظام'
|
||||
);
|
||||
}
|
||||
|
||||
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||
const loanNumber = await this.generateLoanNumber();
|
||||
const installments = data.installments || 1;
|
||||
const monthlyAmount = data.amount / installments;
|
||||
const loan = await prisma.loan.create({
|
||||
data: {
|
||||
loanNumber,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
amount: data.amount,
|
||||
installments,
|
||||
monthlyAmount,
|
||||
reason: data.reason,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||
return loan;
|
||||
if (!data.reason || !data.reason.trim()) {
|
||||
throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
|
||||
}
|
||||
|
||||
if (!data.amount || Number(data.amount) <= 0) {
|
||||
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
|
||||
}
|
||||
|
||||
const loanNumber = await this.generateLoanNumber();
|
||||
const installments = data.installments || 1;
|
||||
const monthlyAmount = data.amount / installments;
|
||||
|
||||
const loan = await prisma.loan.create({
|
||||
data: {
|
||||
loanNumber,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
amount: data.amount,
|
||||
installments,
|
||||
monthlyAmount,
|
||||
reason: data.reason.trim(),
|
||||
status: 'PENDING_HR',
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||
return loan;
|
||||
}
|
||||
|
||||
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
||||
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
|
||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
|
||||
const loan = await prisma.loan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
installmentsList: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
basicSalary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||
let d = new Date(startDate);
|
||||
for (let i = 1; i <= loan.installments; i++) {
|
||||
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
}
|
||||
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.loan.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
|
||||
}),
|
||||
...installments.map((inst) =>
|
||||
prisma.loanInstallment.create({
|
||||
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
|
||||
return this.findLoanById(id);
|
||||
if (!loan) {
|
||||
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
}
|
||||
|
||||
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
|
||||
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
|
||||
}
|
||||
|
||||
const approverUser = await prisma.user.findUnique({
|
||||
where: { id: approvedBy },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!approverUser) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
const isSystemAdmin = this.isSystemAdminUser(approverUser);
|
||||
const basicSalary = Number(loan.employee?.basicSalary || 0);
|
||||
const loanAmount = Number(loan.amount || 0);
|
||||
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
||||
|
||||
// المرحلة الأولى: HR approval
|
||||
if (loan.status === 'PENDING_HR') {
|
||||
if (needsAdminApproval) {
|
||||
const updatedLoan = await prisma.loan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PENDING_ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LOAN',
|
||||
entityId: id,
|
||||
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||
userId,
|
||||
});
|
||||
|
||||
return updatedLoan;
|
||||
}
|
||||
}
|
||||
|
||||
// المرحلة الثانية: Admin approval إذا تجاوز 50%
|
||||
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
|
||||
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
|
||||
}
|
||||
|
||||
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||
let d = new Date(startDate);
|
||||
|
||||
for (let i = 1; i <= loan.installments; i++) {
|
||||
installments.push({
|
||||
installmentNumber: i,
|
||||
dueDate: new Date(d),
|
||||
amount: monthlyAmount,
|
||||
});
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
}
|
||||
|
||||
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.loan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
}),
|
||||
...installments.map((inst) =>
|
||||
prisma.loanInstallment.create({
|
||||
data: {
|
||||
loanId: id,
|
||||
installmentNumber: inst.installmentNumber,
|
||||
dueDate: inst.dueDate,
|
||||
amount: inst.amount,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LOAN',
|
||||
entityId: id,
|
||||
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return this.findLoanById(id);
|
||||
}
|
||||
|
||||
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||
const loan = await prisma.loan.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', rejectedReason },
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
||||
return loan;
|
||||
const existing = await prisma.loan.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
}
|
||||
|
||||
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
|
||||
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
|
||||
}
|
||||
|
||||
const loan = await prisma.loan.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', rejectedReason },
|
||||
include: { employee: true },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LOAN',
|
||||
entityId: id,
|
||||
action: 'REJECT',
|
||||
userId,
|
||||
reason: rejectedReason,
|
||||
});
|
||||
|
||||
return loan;
|
||||
}
|
||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||
await prisma.loanInstallment.update({
|
||||
where: { id: installmentId },
|
||||
@@ -751,16 +1120,41 @@ class HRService {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||
const ent = await prisma.leaveEntitlement.upsert({
|
||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
|
||||
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
|
||||
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||
return ent;
|
||||
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
|
||||
|
||||
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||
throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
|
||||
}
|
||||
|
||||
const ent = await prisma.leaveEntitlement.upsert({
|
||||
where: {
|
||||
employeeId_year_leaveType: {
|
||||
employeeId: data.employeeId,
|
||||
year: data.year,
|
||||
leaveType: normalizedLeaveType,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
employeeId: data.employeeId,
|
||||
year: data.year,
|
||||
leaveType: normalizedLeaveType,
|
||||
totalDays: data.totalDays,
|
||||
carriedOver: data.carriedOver || 0,
|
||||
notes: data.notes,
|
||||
},
|
||||
update: {
|
||||
totalDays: data.totalDays,
|
||||
carriedOver: data.carriedOver ?? undefined,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||
return ent;
|
||||
}
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
private async generateContractNumber(): Promise<string> {
|
||||
|
||||
@@ -49,6 +49,100 @@ export class PortalController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
async getManagedLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const leaves = await portalService.getManagedLeaves(req.user?.employeeId, status);
|
||||
res.json(ResponseFormatter.success(leaves));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const leave = await portalService.approveManagedLeave(req.user?.employeeId, req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(leave, 'تمت الموافقة على الإجازة من قبل مدير القسم - Leave approved by department manager'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const leave = await portalService.rejectManagedLeave(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
rejectedReason || '',
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة من قبل مدير القسم - Leave rejected by department manager'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getMyOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await portalService.getMyOvertimeRequests(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = {
|
||||
date: req.body.date,
|
||||
hours: req.body.hours,
|
||||
reason: req.body.reason,
|
||||
};
|
||||
|
||||
const result = await portalService.submitOvertimeRequest(req.user?.employeeId, data, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(result, 'تم إرسال طلب الساعات الإضافية'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getManagedOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await portalService.getManagedOvertimeRequests(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.approveManagedOvertimeRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.attendanceId,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تمت الموافقة على طلب الساعات الإضافية'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.rejectManagedOvertimeRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.attendanceId,
|
||||
req.body.rejectedReason || '',
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم رفض طلب الساعات الإضافية'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,10 @@ class PortalService {
|
||||
position: { select: { title: true, titleAr: true } },
|
||||
},
|
||||
});
|
||||
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||
@@ -46,6 +49,66 @@ class PortalService {
|
||||
};
|
||||
}
|
||||
|
||||
private buildOvertimeRequestNote(
|
||||
hours: number,
|
||||
reason: string,
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED',
|
||||
rejectedReason?: string
|
||||
) {
|
||||
const safeReason = String(reason || '').replace(/\|/g, '/').trim();
|
||||
const safeRejectedReason = String(rejectedReason || '').replace(/\|/g, '/').trim();
|
||||
|
||||
let note = `OT_REQUEST|status=${status}|hours=${hours}|reason=${safeReason}`;
|
||||
if (status === 'REJECTED' && safeRejectedReason) {
|
||||
note += `|rejectedReason=${safeRejectedReason}`;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
private isOvertimeRequestNote(notes?: string | null) {
|
||||
return !!notes && notes.startsWith('OT_REQUEST|');
|
||||
}
|
||||
|
||||
private parseOvertimeRequestNote(notes?: string | null) {
|
||||
if (!notes || !notes.startsWith('OT_REQUEST|')) return null;
|
||||
|
||||
const parts = notes.split('|');
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
for (const part of parts.slice(1)) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx > -1) {
|
||||
const key = part.slice(0, idx);
|
||||
const value = part.slice(idx + 1);
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: data.status || 'PENDING',
|
||||
hours: Number(data.hours || 0),
|
||||
reason: data.reason || '',
|
||||
rejectedReason: data.rejectedReason || '',
|
||||
};
|
||||
}
|
||||
|
||||
private formatOvertimeRequest(attendance: any) {
|
||||
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||
if (!parsed) return null;
|
||||
|
||||
return {
|
||||
id: attendance.id,
|
||||
attendanceId: attendance.id,
|
||||
date: attendance.date,
|
||||
hours: parsed.hours || Number(attendance.overtimeHours || 0),
|
||||
reason: parsed.reason,
|
||||
status: parsed.status,
|
||||
rejectedReason: parsed.rejectedReason || '',
|
||||
createdAt: attendance.createdAt,
|
||||
employee: attendance.employee,
|
||||
};
|
||||
}
|
||||
|
||||
async getMyLoans(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return prisma.loan.findMany({
|
||||
@@ -55,7 +118,268 @@ class PortalService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||
async getMyOvertimeRequests(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: {
|
||||
employeeId: empId,
|
||||
notes: {
|
||||
startsWith: 'OT_REQUEST|',
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return rows
|
||||
.map((row) => this.formatOvertimeRequest(row))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async submitOvertimeRequest(
|
||||
employeeId: string | undefined,
|
||||
data: { date: string; hours: number; reason: string },
|
||||
userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
|
||||
if (!data.date) {
|
||||
throw new AppError(400, 'تاريخ الساعات الإضافية مطلوب');
|
||||
}
|
||||
|
||||
if (!data.hours || Number(data.hours) <= 0) {
|
||||
throw new AppError(400, 'عدد الساعات غير صالح');
|
||||
}
|
||||
|
||||
if (!data.reason || !data.reason.trim()) {
|
||||
throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
|
||||
}
|
||||
|
||||
const requestDate = new Date(data.date);
|
||||
const note = this.buildOvertimeRequestNote(Number(data.hours), data.reason.trim(), 'PENDING');
|
||||
|
||||
const existing = await prisma.attendance.findFirst({
|
||||
where: {
|
||||
employeeId: empId,
|
||||
date: requestDate,
|
||||
},
|
||||
});
|
||||
|
||||
let attendance;
|
||||
|
||||
if (existing) {
|
||||
attendance = await prisma.attendance.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
overtimeHours: Number(data.hours),
|
||||
notes: note,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
attendance = await prisma.attendance.create({
|
||||
data: {
|
||||
employeeId: empId,
|
||||
date: requestDate,
|
||||
status: 'PRESENT',
|
||||
overtimeHours: Number(data.hours),
|
||||
notes: note,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatOvertimeRequest(attendance);
|
||||
}
|
||||
|
||||
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: {
|
||||
notes: {
|
||||
startsWith: 'OT_REQUEST|',
|
||||
},
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return rows
|
||||
.map((row) => this.formatOvertimeRequest(row))
|
||||
.filter((row: any) => row && row.status === 'PENDING');
|
||||
}
|
||||
|
||||
async approveManagedOvertimeRequest(
|
||||
managerEmployeeId: string | undefined,
|
||||
attendanceId: string,
|
||||
userId: string
|
||||
) {
|
||||
this.requireEmployeeId(managerEmployeeId);
|
||||
|
||||
const attendance = await prisma.attendance.findUnique({
|
||||
where: { id: attendanceId },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
reportingToId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!attendance) {
|
||||
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||
}
|
||||
|
||||
if (!this.isOvertimeRequestNote(attendance.notes)) {
|
||||
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||
}
|
||||
|
||||
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||
if (!parsed || parsed.status !== 'PENDING') {
|
||||
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||
}
|
||||
|
||||
const updatedNote = this.buildOvertimeRequestNote(parsed.hours, parsed.reason, 'APPROVED');
|
||||
|
||||
const updated = await prisma.attendance.update({
|
||||
where: { id: attendanceId },
|
||||
data: {
|
||||
overtimeHours: parsed.hours,
|
||||
notes: updatedNote,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.formatOvertimeRequest(updated);
|
||||
}
|
||||
|
||||
|
||||
async rejectManagedOvertimeRequest(
|
||||
managerEmployeeId: string | undefined,
|
||||
attendanceId: string,
|
||||
rejectedReason: string,
|
||||
userId: string
|
||||
) {
|
||||
this.requireEmployeeId(managerEmployeeId);
|
||||
|
||||
if (!rejectedReason || !rejectedReason.trim()) {
|
||||
throw new AppError(400, 'سبب الرفض مطلوب');
|
||||
}
|
||||
|
||||
const attendance = await prisma.attendance.findUnique({
|
||||
where: { id: attendanceId },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
reportingToId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!attendance) {
|
||||
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||
}
|
||||
|
||||
if (!this.isOvertimeRequestNote(attendance.notes)) {
|
||||
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||
}
|
||||
|
||||
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||
if (!parsed || parsed.status !== 'PENDING') {
|
||||
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||
}
|
||||
|
||||
const updatedNote = this.buildOvertimeRequestNote(
|
||||
parsed.hours,
|
||||
parsed.reason,
|
||||
'REJECTED',
|
||||
rejectedReason.trim()
|
||||
);
|
||||
|
||||
const updated = await prisma.attendance.update({
|
||||
where: { id: attendanceId },
|
||||
data: {
|
||||
notes: updatedNote,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.formatOvertimeRequest(updated);
|
||||
}
|
||||
|
||||
|
||||
async submitLoanRequest(
|
||||
employeeId: string | undefined,
|
||||
data: { type: string; amount: number; installments?: number; reason?: string },
|
||||
userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
@@ -75,7 +399,31 @@ class PortalService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
|
||||
async getManagedLeaves(employeeId: string | undefined, status?: string) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
return hrService.findManagedLeaves(status);
|
||||
}
|
||||
|
||||
async approveManagedLeave(employeeId: string | undefined, leaveId: string, userId: string) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
return hrService.managerApproveLeave(leaveId, userId);
|
||||
}
|
||||
|
||||
async rejectManagedLeave(
|
||||
employeeId: string | undefined,
|
||||
leaveId: string,
|
||||
rejectedReason: string,
|
||||
userId: string
|
||||
) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
return hrService.managerRejectLeave(leaveId, rejectedReason, userId);
|
||||
}
|
||||
|
||||
async submitLeaveRequest(
|
||||
employeeId: string | undefined,
|
||||
data: { leaveType: string; startDate: Date; endDate: Date; reason?: string },
|
||||
userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
@@ -88,7 +436,11 @@ class PortalService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
|
||||
async submitPurchaseRequest(
|
||||
employeeId: string | undefined,
|
||||
data: { items: any[]; reason?: string; priority?: string },
|
||||
userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
@@ -111,4 +463,4 @@ class PortalService {
|
||||
}
|
||||
}
|
||||
|
||||
export const portalService = new PortalService();
|
||||
export const portalService = new PortalService();
|
||||
Reference in New Issue
Block a user