diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index 07b0764..d19c09f 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -1,6 +1,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; +import { notificationsService } from '../notifications/notifications.service'; class HRService { // ========== EMPLOYEES ========== @@ -352,15 +353,40 @@ class HRService { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'CREATE', userId, }); + const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'leave_requests', + fallbackEmployeeId: leave.employeeId, + fallbackToManager: true, + type: 'LEAVE_REQUEST_SUBMITTED', + title: 'طلب إجازة جديد بانتظار الموافقة', + message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`, + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: leave.employeeId, + type: 'LEAVE_REQUEST_CREATED', + title: 'تم إرسال طلب الإجازة', + message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.', + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [], + }); + return leave; } + async approveLeave(id: string, approvedBy: string, userId: string) { const leave = await prisma.leave.update({ where: { id }, @@ -378,16 +404,25 @@ class HRService { const year = new Date(leave.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'APPROVE', userId, }); + await notificationsService.notifyEmployeeUser({ + employeeId: leave.employeeId, + type: 'LEAVE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الإجازة', + message: 'تمت الموافقة على طلب الإجازة الخاص بك.', + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [userId], + }); + return leave; } - async rejectLeave(id: string, rejectedReason: string, userId: string) { const leave = await prisma.leave.findUnique({ where: { id } }); if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found'); @@ -403,13 +438,26 @@ class HRService { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: id, action: 'REJECT', userId, reason: rejectedReason, }); + + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_REJECTED', + title: 'تم رفض طلب الإجازة', + message: rejectedReason + ? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}` + : 'تم رفض طلب الإجازة الخاص بك.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -566,13 +614,23 @@ async findManagedLeaves(status?: string) { const year = new Date(updated.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_APPROVE', userId, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الإجازة', + message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -610,7 +668,7 @@ async findManagedLeaves(status?: string) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_REJECT', @@ -618,6 +676,18 @@ async findManagedLeaves(status?: string) { reason: rejectedReason, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_REJECTED', + title: 'تم رفض طلب الإجازة', + message: rejectedReason + ? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}` + : 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -843,7 +913,30 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); + await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); + + const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'loan_requests', + type: 'LOAN_REQUEST_SUBMITTED', + title: 'طلب قرض جديد بانتظار الموافقة', + message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`, + entityType: 'LOAN', + entityId: loan.id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: loan.employeeId, + type: 'LOAN_REQUEST_CREATED', + title: 'تم إرسال طلب القرض', + message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`, + entityType: 'LOAN', + entityId: loan.id, + excludeUserIds: [], + }); + return loan; } @@ -899,13 +992,36 @@ private isSystemAdminUser(user: any) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'HR_APPROVE_FORWARD_TO_ADMIN', userId, }); + const fullLoan = await this.findLoanById(id); + const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'loan_requests', + type: 'LOAN_REQUEST_PENDING_ADMIN', + title: 'طلب قرض محال إلى مدير النظام', + message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`, + entityType: 'LOAN', + entityId: id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: fullLoan.employee.id, + type: 'LOAN_REQUEST_ESCALATED', + title: 'تمت إحالة طلب القرض للاعتماد النهائي', + message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`, + entityType: 'LOAN', + entityId: id, + excludeUserIds: [userId], + }); + return updatedLoan; } } @@ -954,14 +1070,26 @@ private isSystemAdminUser(user: any) { ), ]); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE', userId, }); - return this.findLoanById(id); + const approvedLoan = await this.findLoanById(id); + + await notificationsService.notifyEmployeeUser({ + employeeId: approvedLoan.employee.id, + type: 'LOAN_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب القرض', + message: `تمت الموافقة على طلب القرض الخاص بك برقم ${approvedLoan.loanNumber}.`, + entityType: 'LOAN', + entityId: approvedLoan.id, + excludeUserIds: [userId], + }); + + return approvedLoan; } async rejectLoan(id: string, rejectedReason: string, userId: string) { @@ -981,7 +1109,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', @@ -989,6 +1117,18 @@ private isSystemAdminUser(user: any) { reason: rejectedReason, }); + await notificationsService.notifyEmployeeUser({ + employeeId: loan.employeeId, + type: 'LOAN_REQUEST_REJECTED', + title: 'تم رفض طلب القرض', + message: rejectedReason?.trim() + ? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}` + : 'تم رفض طلب القرض الخاص بك.', + entityType: 'LOAN', + entityId: loan.id, + excludeUserIds: [userId], + }); + return loan; } async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { @@ -1063,7 +1203,30 @@ private isSystemAdminUser(user: any) { }, include: { employee: true }, }); - await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); + + const employeeFullName = `${req.employee.firstName} ${req.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'purchase_requests', + type: 'PURCHASE_REQUEST_SUBMITTED', + title: 'طلب شراء جديد بانتظار الموافقة', + message: `قام الموظف ${employeeFullName} بإرسال طلب شراء جديد برقم ${req.requestNumber}.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: req.employeeId, + type: 'PURCHASE_REQUEST_CREATED', + title: 'تم إرسال طلب الشراء', + message: `تم إرسال طلب الشراء الخاص بك برقم ${req.requestNumber} وهو الآن بانتظار المراجعة.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [], + }); + return req; } @@ -1073,8 +1236,19 @@ private isSystemAdminUser(user: any) { data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null }, include: { employee: true }, }); - await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); - return req; + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); + + await notificationsService.notifyEmployeeUser({ + employeeId: req.employeeId, + type: 'PURCHASE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الشراء', + message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [userId], + }); + + return req;; } async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { @@ -1084,6 +1258,19 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); + + await notificationsService.notifyEmployeeUser({ + employeeId: req.employeeId, + type: 'PURCHASE_REQUEST_REJECTED', + title: 'تم رفض طلب الشراء', + message: rejectedReason?.trim() + ? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}` + : `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [userId], + }); + return req; } diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index fd56e21..e3dc158 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -1,6 +1,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { hrService } from './hr.service'; +import { notificationsService } from '../notifications/notifications.service'; class PortalService { private requireEmployeeId(employeeId: string | undefined): string { @@ -212,8 +213,32 @@ class PortalService { }); } + const employeeFullName = `${attendance.employee.firstName} ${attendance.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'overtime_requests', + fallbackEmployeeId: attendance.employeeId, + fallbackToManager: true, + type: 'OVERTIME_REQUEST_SUBMITTED', + title: 'طلب ساعات إضافية جديد بانتظار الموافقة', + message: `قام الموظف ${employeeFullName} بإرسال طلب ساعات إضافية جديد.`, + entityType: 'OVERTIME_REQUEST', + entityId: attendance.id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: attendance.employeeId, + type: 'OVERTIME_REQUEST_CREATED', + title: 'تم إرسال طلب الساعات الإضافية', + message: 'تم إرسال طلب الساعات الإضافية الخاص بك بنجاح وهو الآن بانتظار المراجعة.', + entityType: 'OVERTIME_REQUEST', + entityId: attendance.id, + excludeUserIds: [], + }); + return this.formatOvertimeRequest(attendance); - } + } async getManagedOvertimeRequests(employeeId: string | undefined) { this.requireEmployeeId(employeeId); @@ -302,6 +327,16 @@ class PortalService { }, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'OVERTIME_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الساعات الإضافية', + message: 'تمت الموافقة على طلب الساعات الإضافية الخاص بك.', + entityType: 'OVERTIME_REQUEST', + entityId: updated.id, + excludeUserIds: [userId], + }); + return this.formatOvertimeRequest(updated); } @@ -371,6 +406,18 @@ class PortalService { }, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'OVERTIME_REQUEST_REJECTED', + title: 'تم رفض طلب الساعات الإضافية', + message: rejectedReason?.trim() + ? `تم رفض طلب الساعات الإضافية الخاص بك. السبب: ${rejectedReason.trim()}` + : 'تم رفض طلب الساعات الإضافية الخاص بك.', + entityType: 'OVERTIME_REQUEST', + entityId: updated.id, + excludeUserIds: [userId], + }); + return this.formatOvertimeRequest(updated); } diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..4bd1ea8 --- /dev/null +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,63 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; +import { notificationsService } from './notifications.service'; + +class NotificationsController { + async listMyNotifications(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = Number(req.query.page || 1); + const pageSize = Number(req.query.pageSize || 20); + + const result = await notificationsService.listMyNotifications( + req.user!.id, + page, + pageSize + ); + + res.json( + ResponseFormatter.success(result) + ); + } catch (error) { + next(error); + } + } + + async getUnreadCount(req: AuthRequest, res: Response, next: NextFunction) { + try { + const result = await notificationsService.getUnreadCount(req.user!.id); + res.json(ResponseFormatter.success(result)); + } catch (error) { + next(error); + } + } + + async markAsRead(req: AuthRequest, res: Response, next: NextFunction) { + try { + const notification = await notificationsService.markAsRead( + req.params.id, + req.user!.id + ); + + res.json( + ResponseFormatter.success(notification, 'تم تعليم الإشعار كمقروء - Notification marked as read') + ); + } catch (error) { + next(error); + } + } + + async markAllAsRead(req: AuthRequest, res: Response, next: NextFunction) { + try { + const result = await notificationsService.markAllAsRead(req.user!.id); + + res.json( + ResponseFormatter.success(result, 'تم تعليم كل الإشعارات كمقروءة - All notifications marked as read') + ); + } catch (error) { + next(error); + } + } +} + +export const notificationsController = new NotificationsController(); \ No newline at end of file diff --git a/backend/src/modules/notifications/notifications.routes.ts b/backend/src/modules/notifications/notifications.routes.ts new file mode 100644 index 0000000..116cef1 --- /dev/null +++ b/backend/src/modules/notifications/notifications.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { authenticate } from '../../shared/middleware/auth'; +import { notificationsController } from './notifications.controller'; + +const router = Router(); + +router.use(authenticate); + +router.get('/my', notificationsController.listMyNotifications); +router.get('/unread-count', notificationsController.getUnreadCount); +router.patch('/:id/read', notificationsController.markAsRead); +router.patch('/read-all', notificationsController.markAllAsRead); + +export default router; \ No newline at end of file diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..1ecdb4a --- /dev/null +++ b/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,404 @@ +import prisma from '../../config/database'; +import { AppError } from '../../shared/middleware/errorHandler'; + +type CreateNotificationInput = { + userId: string; + type: string; + title: string; + message: string; + entityType?: string | null; + entityId?: string | null; +}; + +type NotifyManyInput = { + userIds: string[]; + type: string; + title: string; + message: string; + entityType?: string | null; + entityId?: string | null; + excludeUserIds?: string[]; +}; + +class NotificationsService { + async listMyNotifications(userId: string, page = 1, pageSize = 20) { + const skip = (page - 1) * pageSize; + + const [total, notifications] = await Promise.all([ + prisma.notification.count({ + where: { userId }, + }), + prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + ]); + + return { + notifications, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; + } + + async getUnreadCount(userId: string) { + const count = await prisma.notification.count({ + where: { + userId, + isRead: false, + }, + }); + + return { count }; + } + + async markAsRead(notificationId: string, userId: string) { + const notification = await prisma.notification.findFirst({ + where: { + id: notificationId, + userId, + }, + }); + + if (!notification) { + throw new AppError(404, 'الإشعار غير موجود - Notification not found'); + } + + return prisma.notification.update({ + where: { id: notificationId }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + } + + async markAllAsRead(userId: string) { + await prisma.notification.updateMany({ + where: { + userId, + isRead: false, + }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + + return { success: true }; + } + + async create(input: CreateNotificationInput) { + return prisma.notification.create({ + data: { + userId: input.userId, + type: input.type, + title: input.title, + message: input.message, + entityType: input.entityType || null, + entityId: input.entityId || null, + }, + }); + } + + async notifyMany(input: NotifyManyInput) { + const excluded = new Set(input.excludeUserIds || []); + const uniqueUserIds = Array.from( + new Set(input.userIds.filter((id) => !!id && !excluded.has(id))) + ); + + if (uniqueUserIds.length === 0) { + return []; + } + + const created = []; + for (const userId of uniqueUserIds) { + const notification = await prisma.notification.create({ + data: { + userId, + type: input.type, + title: input.title, + message: input.message, + entityType: input.entityType || null, + entityId: input.entityId || null, + }, + }); + created.push(notification); + } + + return created; + } + + async findUserByEmployeeId(employeeId: string) { + return prisma.user.findFirst({ + where: { + employeeId, + isActive: true, + }, + select: { + id: true, + employeeId: true, + email: true, + username: true, + }, + }); + } + + async findManagerUserByEmployeeId(employeeId: string) { + const employee = await prisma.employee.findUnique({ + where: { id: employeeId }, + select: { + id: true, + reportingToId: true, + }, + }); + + if (!employee?.reportingToId) { + return null; + } + + return prisma.user.findFirst({ + where: { + employeeId: employee.reportingToId, + isActive: true, + }, + select: { + id: true, + employeeId: true, + email: true, + username: true, + }, + }); + } + + async notifyEmployeeUser(params: { + employeeId: string; + type: string; + title: string; + message: string; + entityType?: string; + entityId?: string; + excludeUserIds?: string[]; + }) { + const user = await this.findUserByEmployeeId(params.employeeId); + if (!user) return null; + + return this.notifyMany({ + userIds: [user.id], + type: params.type, + title: params.title, + message: params.message, + entityType: params.entityType, + entityId: params.entityId, + excludeUserIds: params.excludeUserIds, + }); + } + + async notifyManagerForEmployee(params: { + employeeId: string; + type: string; + title: string; + message: string; + entityType?: string; + entityId?: string; + excludeUserIds?: string[]; + }) { + const managerUser = await this.findManagerUserByEmployeeId(params.employeeId); + if (!managerUser) return null; + + return this.notifyMany({ + userIds: [managerUser.id], + type: params.type, + title: params.title, + message: params.message, + entityType: params.entityType, + entityId: params.entityId, + excludeUserIds: params.excludeUserIds, + }); + } + + async resolveApprovalRecipients(params: { + resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests'; + fallbackEmployeeId?: string; + fallbackToManager?: boolean; + excludeUserIds?: string[]; + }) { + const notifyUsers = await this.findUsersWithPermission({ + module: params.resource, + resource: 'all', + action: 'notify', + excludeUserIds: params.excludeUserIds, + }); + + if (notifyUsers.length > 0) { + return notifyUsers; + } + + const approveUsers = await this.findUsersWithPermission({ + module: params.resource, + resource: 'all', + action: 'approve', + excludeUserIds: params.excludeUserIds, + }); + + if (approveUsers.length > 0) { + return approveUsers; + } + + if (params.fallbackToManager && params.fallbackEmployeeId) { + const managerUser = await this.findManagerUserByEmployeeId(params.fallbackEmployeeId); + if (managerUser) { + return [managerUser]; + } + } + + return []; + } + + async notifyApprovalRecipients(params: { + resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests'; + fallbackEmployeeId?: string; + fallbackToManager?: boolean; + type: string; + title: string; + message: string; + entityType?: string; + entityId?: string; + excludeUserIds?: string[]; + }) { + const recipients = await this.resolveApprovalRecipients({ + resource: params.resource, + fallbackEmployeeId: params.fallbackEmployeeId, + fallbackToManager: params.fallbackToManager, + excludeUserIds: params.excludeUserIds, + }); + + return this.notifyMany({ + userIds: recipients.map((u) => u.id), + type: params.type, + title: params.title, + message: params.message, + entityType: params.entityType, + entityId: params.entityId, + excludeUserIds: params.excludeUserIds, + }); + } + + async findUsersWithPermission(params: { + module: string; + resource?: string; + action: string; + departmentId?: string; + excludeUserIds?: string[]; + }) { + const users = await prisma.user.findMany({ + where: { + isActive: true, + employee: { + status: 'ACTIVE', + }, + }, + include: { + employee: { + include: { + position: { + include: { + permissions: true, + }, + }, + department: true, + }, + }, + userRoles: { + where: { + role: { + isActive: true, + }, + }, + include: { + role: { + include: { + permissions: true, + }, + }, + }, + }, + }, + }); + + const resource = params.resource || '*'; + const excluded = new Set(params.excludeUserIds || []); + + const matched = users.filter((user: any) => { + if (excluded.has(user.id)) return false; + if (!user.employee) return false; + + if (params.departmentId && user.employee.departmentId !== params.departmentId) { + return false; + } + + const positionPerms = user.employee?.position?.permissions || []; + const rolePerms = (user.userRoles || []).flatMap((ur: any) => ur.role?.permissions || []); + const allPerms = [...positionPerms, ...rolePerms]; + + return allPerms.some((perm: any) => { + const moduleMatch = perm.module === params.module; + const resourceMatch = + perm.resource === resource || + perm.resource === '*' || + perm.resource === 'all'; + const actions = Array.isArray(perm.actions) ? perm.actions : []; + const actionMatch = + actions.includes(params.action) || + actions.includes('*') || + actions.includes('all'); + + return moduleMatch && resourceMatch && actionMatch; + }); + }); + + return matched.map((u: any) => ({ + id: u.id, + employeeId: u.employeeId, + username: u.username, + email: u.email, + })); + } + + async notifyUsersWithPermission(params: { + module: string; + resource?: string; + action: string; + departmentId?: string; + type: string; + title: string; + message: string; + entityType?: string; + entityId?: string; + excludeUserIds?: string[]; + }) { + const users = await this.findUsersWithPermission({ + module: params.module, + resource: params.resource, + action: params.action, + departmentId: params.departmentId, + excludeUserIds: params.excludeUserIds, + }); + + return this.notifyMany({ + userIds: users.map((u) => u.id), + type: params.type, + title: params.title, + message: params.message, + entityType: params.entityType, + entityId: params.entityId, + excludeUserIds: params.excludeUserIds, + }); + } +} + +export const notificationsService = new NotificationsService(); \ No newline at end of file diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index cfe3798..1c0ee4f 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -1,6 +1,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; +import { notificationsService } from '../notifications/notifications.service'; import { Prisma } from '@prisma/client'; import path from 'path'; import fs from 'fs' @@ -517,20 +518,20 @@ class TendersService { }, }); - const assignedUser = directive.assignedToEmployee?.user; - if (assignedUser?.id) { - const typeLabel = this.getDirectiveTypeLabel(data.type); - await prisma.notification.create({ - data: { - userId: assignedUser.id, - type: 'TENDER_DIRECTIVE_ASSIGNED', - title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`, - message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`, - entityType: 'TENDER_DIRECTIVE', - entityId: directive.id, - }, - }); - } + const assignedUser = directive.assignedToEmployee?.user; + if (assignedUser?.id) { + const typeLabel = this.getDirectiveTypeLabel(data.type); + + await notificationsService.notifyMany({ + userIds: [assignedUser.id], + type: 'TENDER_DIRECTIVE_ASSIGNED', + title: 'تم إسناد توجيه مناقصة جديد', + message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`, + entityType: 'TENDER', + entityId: tender.id, + excludeUserIds: [userId], + }); + } await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index d94ec76..dfc403c 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -9,6 +9,7 @@ import inventoryRoutes from '../modules/inventory/inventory.routes'; import projectsRoutes from '../modules/projects/projects.routes'; import marketingRoutes from '../modules/marketing/marketing.routes'; import tendersRoutes from '../modules/tenders/tenders.routes'; +import notificationsRoutes from '../modules/notifications/notifications.routes'; const router = Router(); @@ -23,6 +24,7 @@ router.use('/inventory', inventoryRoutes); router.use('/projects', projectsRoutes); router.use('/marketing', marketingRoutes); router.use('/tenders', tendersRoutes); +router.use('/notifications', notificationsRoutes); // API info router.get('/', (req, res) => { diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx index 46ecc6f..9b4b7b2 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -13,9 +13,17 @@ const MODULES = [ { id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, + { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, + + { id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' }, + { id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' }, + { id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' }, + { id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' }, + { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, - { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime 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' }, @@ -28,6 +36,7 @@ const ACTIONS = [ { id: 'delete', name: 'حذف' }, { id: 'export', name: 'تصدير' }, { id: 'approve', name: 'اعتماد' }, + { id: 'notify', name: 'إشعار' }, { id: 'merge', name: 'دمج' }, ]; diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index c73ac46..4ef25eb 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -14,9 +14,17 @@ const MODULES = [ { id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, + { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, + + { id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' }, + { id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' }, + { id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' }, + { id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' }, + { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, - { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime 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' }, @@ -29,6 +37,7 @@ const ACTIONS = [ { id: 'delete', name: 'حذف' }, { id: 'export', name: 'تصدير' }, { id: 'approve', name: 'اعتماد' }, + { id: 'notify', name: 'إشعار' }, { id: 'merge', name: 'دمج' }, ]; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 1d975b1..74cb6c8 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import Image from 'next/image' import logoImage from '@/assets/logo.png' import ProtectedRoute from '@/components/ProtectedRoute' @@ -8,6 +8,7 @@ import { useAuth } from '@/contexts/AuthContext' import { useLanguage } from '@/contexts/LanguageContext' import LanguageSwitcher from '@/components/LanguageSwitcher' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { Users, User, @@ -23,21 +24,191 @@ import { Shield, FileText } from 'lucide-react' -import { dashboardAPI } from '@/lib/api' +import { dashboardAPI, notificationsAPI } from '@/lib/api' +import { portalAPI } from '@/lib/api/portal' +import { hrAdminAPI } from '@/lib/api/hrAdmin' function DashboardContent() { - const { user, logout, hasPermission } = useAuth() + const { user, logout, hasPermission } = useAuth() + const router = useRouter() const { t, language, dir } = useLanguage() - const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 }) + + const [stats, setStats] = useState({ + contacts: 0, + activeTasks: 0, + notifications: 0, + }) + + const [showNotifications, setShowNotifications] = useState(false) + const [notifications, setNotifications] = useState([]) + const [notificationsLoading, setNotificationsLoading] = useState(false) + const notificationsRef = useRef(null) + + const [pendingApprovals, setPendingApprovals] = useState({ + managedLeaves: 0, + managedOvertime: 0, + purchaseRequests: 0, + total: 0, + }) useEffect(() => { - dashboardAPI.getStats() + dashboardAPI + .getStats() .then((res) => { if (res.data?.data) setStats(res.data.data) }) .catch(() => {}) }, []) + const loadNotifications = async () => { + setNotificationsLoading(true) + try { + const res = await notificationsAPI.getMy({ page: 1, pageSize: 10 }) + const items = res.data?.data?.notifications || [] + setNotifications(items) + } catch { + setNotifications([]) + } finally { + setNotificationsLoading(false) + } + } + + const markNotificationAsRead = async (id: string) => { + try { + await notificationsAPI.markAsRead(id) + + setNotifications((prev) => + prev.map((item) => + item.id === id + ? { ...item, isRead: true, readAt: new Date().toISOString() } + : item + ) + ) + + setStats((prev) => ({ + ...prev, + notifications: Math.max(0, prev.notifications - 1), + })) + } catch {} + } + + const resolveNotificationUrl = (notification: any) => { + if (notification.entityType === 'LEAVE') { + if (notification.type === 'LEAVE_REQUEST_SUBMITTED') { + return '/portal/managed-leaves' + } + return '/portal/leave' + } + + if (notification.entityType === 'OVERTIME_REQUEST') { + if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') { + return '/portal/managed-overtime-requests' + } + return '/portal/overtime' + } + + if (notification.entityType === 'PURCHASE_REQUEST') { + if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') { + return '/hr?tab=purchases' + } + return '/portal/purchase-requests' + } + + if (notification.entityType === 'LOAN') { + if ( + notification.type === 'LOAN_REQUEST_SUBMITTED' || + notification.type === 'LOAN_REQUEST_PENDING_ADMIN' + ) { + return '/hr?tab=loans' + } + return '/portal/loans' + } + + if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') { + if (notification.entityType === 'TENDER' && notification.entityId) { + return `/tenders/${notification.entityId}?tab=directives` + } + + return '/tenders' + } + + return '/dashboard' + } + + const handleNotificationClick = async (notification: any) => { + if (!notification.isRead) { + await markNotificationAsRead(notification.id) + } + + const targetUrl = resolveNotificationUrl(notification) + setShowNotifications(false) + router.push(targetUrl) + } + + const handleToggleNotifications = async () => { + const next = !showNotifications + setShowNotifications(next) + + if (next) { + await loadNotifications() + } + } + + const loadPendingApprovals = async () => { + try { + const [managedLeaves, managedOvertime, purchaseRequests] = await Promise.all([ + canViewManagedLeaves + ? portalAPI.getManagedLeaves('PENDING') + : Promise.resolve([]), + canViewManagedOvertime + ? portalAPI.getManagedOvertimeRequests() + : Promise.resolve([]), + canApproveHr + ? hrAdminAPI + .getPurchaseRequests({ status: 'PENDING', page: 1, pageSize: 50 }) + .then((r) => r.purchaseRequests || []) + : Promise.resolve([]), + ]) + + const total = + managedLeaves.length + + managedOvertime.length + + purchaseRequests.length + + setPendingApprovals({ + managedLeaves: managedLeaves.length, + managedOvertime: managedOvertime.length, + purchaseRequests: purchaseRequests.length, + total, + }) + } catch { + setPendingApprovals({ + managedLeaves: 0, + managedOvertime: 0, + purchaseRequests: 0, + total: 0, + }) + } + } + + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + notificationsRef.current && + !notificationsRef.current.contains(event.target as Node) + ) { + setShowNotifications(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + const allModules = [ { id: 'contacts', @@ -135,6 +306,9 @@ function DashboardContent() { const availableModules = allModules.filter(module => hasPermission(module.permission, 'view') ) + const canViewManagedLeaves = hasPermission('department_leave_requests', 'view') + const canViewManagedOvertime = hasPermission('department_overtime_requests', 'view') + const canApproveHr = hasPermission('hr', 'approve') return (
@@ -182,12 +356,86 @@ function DashboardContent() { )} {/* Notifications */} - + + {showNotifications && ( +
+
+

الإشعارات

+ +
+ +
+ {notificationsLoading ? ( +
+ جاري تحميل الإشعارات... +
+ ) : notifications.length === 0 ? ( +
+ لا توجد إشعارات +
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} +
+
)} - +
{/* Settings */} + )} + + {canViewManagedOvertime && ( + + )} + + {canApproveHr && ( + + )} + + + )} + {/* Available Modules */}

الوحدات المتاحة

diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx index a19bc9c..7c828d5 100644 --- a/frontend/src/app/hr/page.tsx +++ b/frontend/src/app/hr/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import ProtectedRoute from '@/components/ProtectedRoute' import Modal from '@/components/Modal' import LoadingSpinner from '@/components/LoadingSpinner' @@ -245,6 +246,8 @@ function EmployeeFormFields({ function HRContent() { // State Management + const router = useRouter() + const searchParams = useSearchParams() const [employees, setEmployees] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -296,7 +299,13 @@ function HRContent() { const [loadingDepts, setLoadingDepts] = useState(false) // Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts - const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees') +type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts' + + const [activeTab, setActiveTab] = useState('employees') + const openTab = (tab: HRTab) => { + setActiveTab(tab) + router.replace(`/hr?tab=${tab}`) + } const [hierarchy, setHierarchy] = useState([]) const [loadingHierarchy, setLoadingHierarchy] = useState(false) @@ -391,6 +400,24 @@ function HRContent() { } }, [activeTab]) + useEffect(() => { + const tabParam = searchParams.get('tab') as HRTab | null + + const allowedTabs: HRTab[] = [ + 'employees', + 'departments', + 'orgchart', + 'leaves', + 'loans', + 'purchases', + 'contracts', + ] + + if (tabParam && allowedTabs.includes(tabParam)) { + setActiveTab(tabParam) + } + }, [searchParams]) + // Fetch Employees (with debouncing for search) const fetchEmployees = useCallback(async () => { setLoading(true) @@ -721,7 +748,7 @@ function HRContent() {