diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 3833917..8ba7162 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -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; \ No newline at end of file diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index dd0781a..07b0764 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -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 { diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index e16adc2..bb3618f 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -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 { diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index 981b86a..fd56e21 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -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 = {}; + + 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(); \ No newline at end of file diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx index cc11f4a..46ecc6f 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -14,6 +14,8 @@ const MODULES = [ { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, + { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, + { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' }, { id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' }, { id: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, { id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' }, diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index f1d0173..c73ac46 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -15,6 +15,8 @@ const MODULES = [ { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, + { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, + { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' }, { id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' }, { id: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, { id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' }, diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 43d3174..1d975b1 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -143,16 +143,14 @@ function DashboardContent() {
-
- Company Logo -
+ Company Logo

ATMATA

نظام إدارة علاقات العملاء

diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx index 4db2520..449088d 100644 --- a/frontend/src/app/hr/page.tsx +++ b/frontend/src/app/hr/page.tsx @@ -372,8 +372,8 @@ function HRContent() { const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 }) setLeavesData(leaves) } else if (activeTab === 'loans') { - const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 }) - setLoansData(loans) + const { loans } = await hrAdminAPI.getLoans({ pageSize: 50 }) + setLoansData(loans.filter((loan: any) => ['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status))) } else if (activeTab === 'purchases') { const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 }) setPurchasesData(purchaseRequests) @@ -1185,19 +1185,75 @@ function HRContent() {

No pending loans

) : (
- {loansData.map((l: any) => ( + {loansData.map((l: any) => { + const salary = Number(l.employee?.basicSalary || 0) + const amount = Number(l.amount || 0) + const needsAdmin = salary > 0 && amount > salary * 0.5 + + return (

{l.employee?.firstName} {l.employee?.lastName}

-

{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)

- {l.reason &&

{l.reason}

} +

+ {l.loanNumber} - {l.type} - {amount.toLocaleString()} SAR ({l.installments} installments) +

+

+ الراتب الأساسي: {salary.toLocaleString()} SAR +

+

+ الحالة: {l.status === 'PENDING_HR' ? 'بانتظار HR' : 'بانتظار مدير النظام'} +

+ {needsAdmin && ( +

+ هذا الطلب يتجاوز 50% من الراتب الأساسي ويحتاج موافقة مدير النظام +

+ )} + {l.reason &&

{l.reason}

}
+
- - + + +
- ))} + ) + })}
)}
diff --git a/frontend/src/app/portal/layout.tsx b/frontend/src/app/portal/layout.tsx index 976b12f..595959c 100644 --- a/frontend/src/app/portal/layout.tsx +++ b/frontend/src/app/portal/layout.tsx @@ -13,18 +13,31 @@ import { DollarSign, Building2, LogOut, - User + User, + CheckCircle2, + TimerReset, } from 'lucide-react' function PortalLayoutContent({ children }: { children: React.ReactNode }) { - const { user, logout } = useAuth() + const { user, logout, hasPermission } = useAuth() const pathname = usePathname() const menuItems = [ { icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true }, { icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' }, { icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' }, - { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' }, + { icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' }, + ...(hasPermission('department_overtime_requests', 'view') + ? [{ + icon: CheckCircle2, + label: 'طلبات الساعات الإضافية', + labelEn: 'Department Overtime Requests', + href: '/portal/managed-overtime-requests' + }] + : []), + ...(hasPermission('department_leave_requests', 'view') + ? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }] + : []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' }, { icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' }, { icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' }, ] diff --git a/frontend/src/app/portal/leave/page.tsx b/frontend/src/app/portal/leave/page.tsx index 97251d4..00e301b 100644 --- a/frontend/src/app/portal/leave/page.tsx +++ b/frontend/src/app/portal/leave/page.tsx @@ -5,13 +5,11 @@ import { portalAPI } from '@/lib/api/portal' import Modal from '@/components/Modal' import LoadingSpinner from '@/components/LoadingSpinner' import { toast } from 'react-hot-toast' -import { Calendar, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' const LEAVE_TYPES = [ { value: 'ANNUAL', label: 'إجازة سنوية' }, - { value: 'SICK', label: 'إجازة مرضية' }, - { value: 'EMERGENCY', label: 'طوارئ' }, - { value: 'UNPAID', label: 'بدون راتب' }, + { value: 'HOURLY', label: 'إجازة ساعية' }, ] const STATUS_MAP: Record = { @@ -26,7 +24,16 @@ export default function PortalLeavePage() { const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [submitting, setSubmitting] = useState(false) - const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' }) + + const [form, setForm] = useState({ + leaveType: 'ANNUAL', + startDate: '', + endDate: '', + leaveDate: '', + startTime: '', + endTime: '', + reason: '', + }) const load = () => { setLoading(true) @@ -43,24 +50,54 @@ export default function PortalLeavePage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - if (!form.startDate || !form.endDate) { - toast.error('أدخل تاريخ البداية والنهاية') - return - } - if (new Date(form.endDate) < new Date(form.startDate)) { - toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية') - return - } - setSubmitting(true) - portalAPI.submitLeaveRequest({ + + let payload: any = { leaveType: form.leaveType, - startDate: form.startDate, - endDate: form.endDate, reason: form.reason || undefined, - }) + } + + if (form.leaveType === 'ANNUAL') { + if (!form.startDate || !form.endDate) { + toast.error('أدخل تاريخ البداية والنهاية') + return + } + + if (new Date(form.endDate) < new Date(form.startDate)) { + toast.error('تاريخ النهاية يجب أن يكون بعد البداية') + return + } + + payload.startDate = form.startDate + payload.endDate = form.endDate + } else { + if (!form.leaveDate || !form.startTime || !form.endTime) { + toast.error('أدخل التاريخ والوقت للإجازة الساعية') + return + } + + if (form.startTime >= form.endTime) { + toast.error('وقت النهاية يجب أن يكون بعد البداية') + return + } + + payload.startDate = `${form.leaveDate}T${form.startTime}:00` + payload.endDate = `${form.leaveDate}T${form.endTime}:00` + } + + setSubmitting(true) + + portalAPI.submitLeaveRequest(payload) .then(() => { setShowModal(false) - setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' }) + setForm({ + leaveType: 'ANNUAL', + startDate: '', + endDate: '', + leaveDate: '', + startTime: '', + endTime: '', + reason: '', + }) toast.success('تم إرسال طلب الإجازة') load() }) @@ -72,6 +109,8 @@ export default function PortalLeavePage() { return (
+ + {/* HEADER */}

إجازاتي

+ {/* الرصيد */} {leaveBalance.length > 0 && (

رصيد الإجازات

@@ -90,36 +130,42 @@ export default function PortalLeavePage() { {leaveBalance.map((b) => (

- {b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType} + {b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType}

{b.available} يوم

-

من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})

))}
)} + {/* الطلبات */}

طلباتي

+ {leaves.length === 0 ? ( -

لا توجد طلبات إجازة

+

لا توجد طلبات

) : (
{leaves.map((l) => { const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' } + return ( -
+

- {l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم + {l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '} + {l.leaveType === 'HOURLY' + ? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` + : `${l.days} يوم`}

+

{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}

- {l.rejectedReason &&

سبب الرفض: {l.rejectedReason}

}
- + + {statusInfo.label}
@@ -129,61 +175,118 @@ export default function PortalLeavePage() { )}
+ {/* الفورم */} setShowModal(false)} title="طلب إجازة جديد">
-
- - -
-
-
- - setForm((p) => ({ ...p, startDate: e.target.value }))} - className="w-full px-3 py-2 border border-gray-300 rounded-lg" - required - /> + + {/* نوع الإجازة */} + + + {/* سنوية */} + {form.leaveType === 'ANNUAL' ? ( +
+
+ + setForm(p => ({ ...p, startDate: e.target.value }))} + className="border p-2 rounded w-full" + /> +
+ +
+ + setForm(p => ({ ...p, endDate: e.target.value }))} + className="border p-2 rounded w-full" + /> +
-
- - setForm((p) => ({ ...p, endDate: e.target.value }))} - className="w-full px-3 py-2 border border-gray-300 rounded-lg" - required - /> + ) : ( + /* ساعية */ +
+
+ + setForm(p => ({ ...p, leaveDate: e.target.value }))} + className="border p-2 rounded w-full" + /> +
+ +
+ + setForm(p => ({ ...p, startTime: e.target.value }))} + className="border p-2 rounded w-full" + /> +
+ +
+ + setForm(p => ({ ...p, endTime: e.target.value }))} + className="border p-2 rounded w-full" + /> +
-
-
- -