notification process
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
class HRService {
|
class HRService {
|
||||||
// ========== EMPLOYEES ==========
|
// ========== EMPLOYEES ==========
|
||||||
@@ -352,15 +353,40 @@ class HRService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId,
|
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;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -378,16 +404,25 @@ class HRService {
|
|||||||
const year = new Date(leave.startDate).getFullYear();
|
const year = new Date(leave.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'APPROVE',
|
action: 'APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
type: 'LEAVE_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الإجازة',
|
||||||
|
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: leave.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return leave;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
const leave = await prisma.leave.findUnique({ where: { id } });
|
const leave = await prisma.leave.findUnique({ where: { id } });
|
||||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
@@ -403,13 +438,26 @@ class HRService {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
userId,
|
userId,
|
||||||
reason: rejectedReason,
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,13 +614,23 @@ async findManagedLeaves(status?: string) {
|
|||||||
const year = new Date(updated.startDate).getFullYear();
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_APPROVE',
|
action: 'MANAGER_APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: updated.employeeId,
|
||||||
|
type: 'LEAVE_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الإجازة',
|
||||||
|
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +668,7 @@ async findManagedLeaves(status?: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_REJECT',
|
action: 'MANAGER_REJECT',
|
||||||
@@ -618,6 +676,18 @@ async findManagedLeaves(status?: string) {
|
|||||||
reason: rejectedReason,
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +913,30 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
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;
|
return loan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,13 +992,36 @@ private isSystemAdminUser(user: any) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
userId,
|
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;
|
return updatedLoan;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -954,14 +1070,26 @@ private isSystemAdminUser(user: any) {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
userId,
|
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) {
|
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||||
@@ -981,7 +1109,7 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
@@ -989,6 +1117,18 @@ private isSystemAdminUser(user: any) {
|
|||||||
reason: rejectedReason,
|
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;
|
return loan;
|
||||||
}
|
}
|
||||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||||
@@ -1063,7 +1203,30 @@ private isSystemAdminUser(user: any) {
|
|||||||
},
|
},
|
||||||
include: { employee: true },
|
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;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,8 +1236,19 @@ private isSystemAdminUser(user: any) {
|
|||||||
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
||||||
return req;
|
|
||||||
|
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) {
|
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
||||||
@@ -1084,6 +1258,19 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
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;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { hrService } from './hr.service';
|
import { hrService } from './hr.service';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
class PortalService {
|
class PortalService {
|
||||||
private requireEmployeeId(employeeId: string | undefined): string {
|
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);
|
return this.formatOvertimeRequest(attendance);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||||
this.requireEmployeeId(employeeId);
|
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);
|
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);
|
return this.formatOvertimeRequest(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
14
backend/src/modules/notifications/notifications.routes.ts
Normal file
14
backend/src/modules/notifications/notifications.routes.ts
Normal file
@@ -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;
|
||||||
404
backend/src/modules/notifications/notifications.service.ts
Normal file
404
backend/src/modules/notifications/notifications.service.ts
Normal file
@@ -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();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
@@ -517,20 +518,20 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignedUser = directive.assignedToEmployee?.user;
|
const assignedUser = directive.assignedToEmployee?.user;
|
||||||
if (assignedUser?.id) {
|
if (assignedUser?.id) {
|
||||||
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
||||||
await prisma.notification.create({
|
|
||||||
data: {
|
await notificationsService.notifyMany({
|
||||||
userId: assignedUser.id,
|
userIds: [assignedUser.id],
|
||||||
type: 'TENDER_DIRECTIVE_ASSIGNED',
|
type: 'TENDER_DIRECTIVE_ASSIGNED',
|
||||||
title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
|
title: 'تم إسناد توجيه مناقصة جديد',
|
||||||
message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
|
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`,
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER',
|
||||||
entityId: directive.id,
|
entityId: tender.id,
|
||||||
},
|
excludeUserIds: [userId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
|
|||||||
import projectsRoutes from '../modules/projects/projects.routes';
|
import projectsRoutes from '../modules/projects/projects.routes';
|
||||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||||
import tendersRoutes from '../modules/tenders/tenders.routes';
|
import tendersRoutes from '../modules/tenders/tenders.routes';
|
||||||
|
import notificationsRoutes from '../modules/notifications/notifications.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ router.use('/inventory', inventoryRoutes);
|
|||||||
router.use('/projects', projectsRoutes);
|
router.use('/projects', projectsRoutes);
|
||||||
router.use('/marketing', marketingRoutes);
|
router.use('/marketing', marketingRoutes);
|
||||||
router.use('/tenders', tendersRoutes);
|
router.use('/tenders', tendersRoutes);
|
||||||
|
router.use('/notifications', notificationsRoutes);
|
||||||
|
|
||||||
// API info
|
// API info
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ const MODULES = [
|
|||||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
|
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ 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_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: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
@@ -28,6 +36,7 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ const MODULES = [
|
|||||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
|
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ 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_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: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
@@ -29,6 +37,7 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import logoImage from '@/assets/logo.png'
|
import logoImage from '@/assets/logo.png'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/contexts/AuthContext'
|
|||||||
import { useLanguage } from '@/contexts/LanguageContext'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
User,
|
User,
|
||||||
@@ -23,21 +24,191 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
FileText
|
FileText
|
||||||
} from 'lucide-react'
|
} 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() {
|
function DashboardContent() {
|
||||||
const { user, logout, hasPermission } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
const { t, language, dir } = useLanguage()
|
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<any[]>([])
|
||||||
|
const [notificationsLoading, setNotificationsLoading] = useState(false)
|
||||||
|
const notificationsRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const [pendingApprovals, setPendingApprovals] = useState({
|
||||||
|
managedLeaves: 0,
|
||||||
|
managedOvertime: 0,
|
||||||
|
purchaseRequests: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dashboardAPI.getStats()
|
dashboardAPI
|
||||||
|
.getStats()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.data?.data) setStats(res.data.data)
|
if (res.data?.data) setStats(res.data.data)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.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 = [
|
const allModules = [
|
||||||
{
|
{
|
||||||
id: 'contacts',
|
id: 'contacts',
|
||||||
@@ -135,6 +306,9 @@ function DashboardContent() {
|
|||||||
const availableModules = allModules.filter(module =>
|
const availableModules = allModules.filter(module =>
|
||||||
hasPermission(module.permission, 'view')
|
hasPermission(module.permission, 'view')
|
||||||
)
|
)
|
||||||
|
const canViewManagedLeaves = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canViewManagedOvertime = hasPermission('department_overtime_requests', 'view')
|
||||||
|
const canApproveHr = hasPermission('hr', 'approve')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
@@ -182,12 +356,86 @@ function DashboardContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
<div className="relative" ref={notificationsRef}>
|
||||||
<Bell className="h-5 w-5 text-gray-600" />
|
<button
|
||||||
{stats.notifications > 0 && (
|
onClick={handleToggleNotifications}
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative"
|
||||||
|
title="الإشعارات"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5 text-gray-600" />
|
||||||
|
{stats.notifications > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute left-0 mt-2 w-96 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">الإشعارات</h3>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await notificationsAPI.markAllAsRead()
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) => ({
|
||||||
|
...item,
|
||||||
|
isRead: true,
|
||||||
|
readAt: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setStats((prev) => ({ ...prev, notifications: 0 }))
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
تعليم الكل كمقروء
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notificationsLoading ? (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">
|
||||||
|
جاري تحميل الإشعارات...
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">
|
||||||
|
لا توجد إشعارات
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<button
|
||||||
|
key={notification.id}
|
||||||
|
onClick={() => handleNotificationClick(notification)}
|
||||||
|
className={`w-full text-right px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||||
|
notification.isRead ? 'bg-white' : 'bg-blue-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2">
|
||||||
|
{new Date(notification.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!notification.isRead && (
|
||||||
|
<span className="mt-1 h-2.5 w-2.5 rounded-full bg-blue-500 flex-shrink-0"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
@@ -268,6 +516,65 @@ function DashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Approvals */}
|
||||||
|
{pendingApprovals.total > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">بانتظار موافقتك</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
كل الطلبات التي تحتاج قرارك الآن
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-100 text-amber-700 px-4 py-2 rounded-lg font-bold">
|
||||||
|
{pendingApprovals.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{canViewManagedLeaves && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/portal/managed-leaves')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات إجازات القسم</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.managedLeaves}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewManagedOvertime && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/portal/managed-overtime-requests')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات الساعات الإضافية</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.managedOvertime}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canApproveHr && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/hr?tab=purchases')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات الشراء</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.purchaseRequests}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Available Modules */}
|
{/* Available Modules */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>
|
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
@@ -245,6 +246,8 @@ function EmployeeFormFields({
|
|||||||
|
|
||||||
function HRContent() {
|
function HRContent() {
|
||||||
// State Management
|
// State Management
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const [employees, setEmployees] = useState<Employee[]>([])
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -296,7 +299,13 @@ function HRContent() {
|
|||||||
const [loadingDepts, setLoadingDepts] = useState(false)
|
const [loadingDepts, setLoadingDepts] = useState(false)
|
||||||
|
|
||||||
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
|
// 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<HRTab>('employees')
|
||||||
|
const openTab = (tab: HRTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(`/hr?tab=${tab}`)
|
||||||
|
}
|
||||||
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
||||||
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
||||||
|
|
||||||
@@ -391,6 +400,24 @@ function HRContent() {
|
|||||||
}
|
}
|
||||||
}, [activeTab])
|
}, [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)
|
// Fetch Employees (with debouncing for search)
|
||||||
const fetchEmployees = useCallback(async () => {
|
const fetchEmployees = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -721,7 +748,7 @@ function HRContent() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<nav className="flex gap-4">
|
<nav className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('employees')}
|
onClick={() => openTab('employees')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'employees'
|
activeTab === 'employees'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { useParams} from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TenderDetailContent() {
|
function TenderDetailContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tenderId = params.id as string
|
const tenderId = params.id as string
|
||||||
@@ -43,7 +45,12 @@ function TenderDetailContent() {
|
|||||||
const [tender, setTender] = useState<Tender | null>(null)
|
const [tender, setTender] = useState<Tender | null>(null)
|
||||||
const [history, setHistory] = useState<any[]>([])
|
const [history, setHistory] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'directives' | 'attachments' | 'history'>('info')
|
type TenderTab = 'details' | 'directives' | 'attachments' | 'logs' | 'info' |'history'
|
||||||
|
const [activeTab, setActiveTab] = useState<TenderTab>('details')
|
||||||
|
const openTab = (tab: TenderTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(`/tenders/${params.id}?tab=${tab}`)
|
||||||
|
}
|
||||||
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
|
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
|
||||||
const [showConvertModal, setShowConvertModal] = useState(false)
|
const [showConvertModal, setShowConvertModal] = useState(false)
|
||||||
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
|
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
|
||||||
@@ -85,6 +92,17 @@ function TenderDetailContent() {
|
|||||||
setHistory(data)
|
setHistory(data)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get('tab') as TenderTab | null
|
||||||
|
|
||||||
|
const allowedTabs: TenderTab[] = ['details', 'directives', 'attachments', 'logs']
|
||||||
|
|
||||||
|
if (tabParam && allowedTabs.includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTender()
|
fetchTender()
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ export const dashboardAPI = {
|
|||||||
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const notificationsAPI = {
|
||||||
|
getMy: (params?: { page?: number; pageSize?: number }) =>
|
||||||
|
api.get('/notifications/my', { params }),
|
||||||
|
|
||||||
|
getUnreadCount: () =>
|
||||||
|
api.get('/notifications/unread-count'),
|
||||||
|
|
||||||
|
markAsRead: (id: string) =>
|
||||||
|
api.patch(`/notifications/${id}/read`),
|
||||||
|
|
||||||
|
markAllAsRead: () =>
|
||||||
|
api.patch('/notifications/read-all'),
|
||||||
|
}
|
||||||
|
|
||||||
export const crmAPI = {
|
export const crmAPI = {
|
||||||
// Deals
|
// Deals
|
||||||
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
||||||
|
|||||||
Reference in New Issue
Block a user