update HR modules

This commit is contained in:
yotakii
2026-04-01 10:17:38 +03:00
parent 94d651c29e
commit 278d8f6982
16 changed files with 1920 additions and 232 deletions

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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 {

View File

@@ -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();