update HR modules
This commit is contained in:
@@ -14,6 +14,46 @@ router.post('/portal/loans', portalController.submitLoanRequest);
|
|||||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/portal/managed-leaves',
|
||||||
|
authorize('department_leave_requests', '*', 'read'),
|
||||||
|
portalController.getManagedLeaves
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-leaves/:id/approve',
|
||||||
|
authorize('department_leave_requests', '*', 'approve'),
|
||||||
|
portalController.approveManagedLeave
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-leaves/:id/reject',
|
||||||
|
authorize('department_leave_requests', '*', 'approve'),
|
||||||
|
portalController.rejectManagedLeave
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||||
|
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/portal/managed-overtime-requests',
|
||||||
|
authorize('department_overtime_requests', '*', 'view'),
|
||||||
|
portalController.getManagedOvertimeRequests
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-overtime-requests/:attendanceId/approve',
|
||||||
|
authorize('department_overtime_requests', '*', 'approve'),
|
||||||
|
portalController.approveManagedOvertimeRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-overtime-requests/:attendanceId/reject',
|
||||||
|
authorize('department_overtime_requests', '*', 'approve'),
|
||||||
|
portalController.rejectManagedOvertimeRequest
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||||
@@ -87,4 +127,3 @@ router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createE
|
|||||||
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -313,13 +313,27 @@ class HRService {
|
|||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
async createLeaveRequest(data: any, userId: string) {
|
async createLeaveRequest(data: any, userId: string) {
|
||||||
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
|
|
||||||
|
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
|
||||||
|
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLeaveType = String(data.leaveType).toUpperCase();
|
||||||
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
||||||
const startDate = new Date(data.startDate);
|
const startDate = new Date(data.startDate);
|
||||||
const year = startDate.getFullYear();
|
const year = startDate.getFullYear();
|
||||||
|
|
||||||
const ent = await prisma.leaveEntitlement.findUnique({
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ent) {
|
if (ent) {
|
||||||
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||||
if (days > available) {
|
if (days > available) {
|
||||||
@@ -330,6 +344,7 @@ class HRService {
|
|||||||
const leave = await prisma.leave.create({
|
const leave = await prisma.leave.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
days,
|
days,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -345,8 +360,7 @@ class HRService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 },
|
||||||
@@ -418,6 +432,195 @@ class HRService {
|
|||||||
return { leaves, total, page, pageSize };
|
return { leaves, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private calculateLeaveHours(startDate: Date, endDate: Date) {
|
||||||
|
const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime();
|
||||||
|
const diffHours = diffMs / (1000 * 60 * 60);
|
||||||
|
return diffHours > 0 ? diffHours : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canManagerApproveLeave(leave: any) {
|
||||||
|
if (leave.leaveType === 'ANNUAL') {
|
||||||
|
const yearStart = new Date(new Date(leave.startDate).getFullYear(), 0, 1);
|
||||||
|
const yearEnd = new Date(new Date(leave.startDate).getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedAnnualLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: yearStart,
|
||||||
|
lte: yearEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
days: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedDays = approvedAnnualLeaves.reduce((sum, item) => sum + Number(item.days || 0), 0);
|
||||||
|
return usedDays + Number(leave.days || 0) <= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.leaveType === 'HOURLY') {
|
||||||
|
const start = new Date(leave.startDate);
|
||||||
|
const monthStart = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedHourlyLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'HOURLY',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: monthStart,
|
||||||
|
lte: monthEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedHours = approvedHourlyLeaves.reduce((sum, item) => {
|
||||||
|
return sum + this.calculateLeaveHours(item.startDate, item.endDate);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const requestedHours = this.calculateLeaveHours(leave.startDate, leave.endDate);
|
||||||
|
return usedHours + requestedHours <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManagedLeaves(status?: string) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.leave.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerApproveLeave(id: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن اعتماد طلب غير معلق - Only pending leave can be approved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canApprove = await this.canManagerApproveLeave(leave);
|
||||||
|
if (!canApprove) {
|
||||||
|
throw new AppError(403, 'الطلب يتجاوز صلاحية مدير القسم ويحتاج موافقة HR - This leave exceeds manager approval limits and requires HR approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
approvedBy: userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerRejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض طلب غير معلق - Only pending leave can be rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason,
|
||||||
|
approvedBy: null,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
||||||
const ent = await prisma.leaveEntitlement.findUnique({
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||||
@@ -539,32 +742,93 @@ class HRService {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
|
|
||||||
const [total, loans] = await Promise.all([
|
const [total, loans] = await Promise.all([
|
||||||
prisma.loan.count({ where }),
|
prisma.loan.count({ where }),
|
||||||
prisma.loan.findMany({
|
prisma.loan.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: true,
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { loans, total, page, pageSize };
|
return { loans, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findLoanById(id: string) {
|
async findLoanById(id: string) {
|
||||||
const loan = await prisma.loan.findUnique({
|
const loan = await prisma.loan.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: { orderBy: { installmentNumber: 'asc' } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
return loan;
|
return loan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSystemAdminUser(user: any) {
|
||||||
|
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
|
||||||
|
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
|
||||||
|
const positionTitleAr = user?.employee?.position?.titleAr || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
positionCode === 'SYS_ADMIN' ||
|
||||||
|
positionCode === 'SYSTEM_ADMIN' ||
|
||||||
|
positionTitle === 'SYSTEM ADMINISTRATOR' ||
|
||||||
|
positionTitleAr === 'مدير النظام'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||||
|
if (!data.reason || !data.reason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.amount || Number(data.amount) <= 0) {
|
||||||
|
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
|
||||||
|
}
|
||||||
|
|
||||||
const loanNumber = await this.generateLoanNumber();
|
const loanNumber = await this.generateLoanNumber();
|
||||||
const installments = data.installments || 1;
|
const installments = data.installments || 1;
|
||||||
const monthlyAmount = data.amount / installments;
|
const monthlyAmount = data.amount / installments;
|
||||||
|
|
||||||
const loan = await prisma.loan.create({
|
const loan = await prisma.loan.create({
|
||||||
data: {
|
data: {
|
||||||
loanNumber,
|
loanNumber,
|
||||||
@@ -573,55 +837,160 @@ class HRService {
|
|||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
installments,
|
installments,
|
||||||
monthlyAmount,
|
monthlyAmount,
|
||||||
reason: data.reason,
|
reason: data.reason.trim(),
|
||||||
status: 'PENDING',
|
status: 'PENDING_HR',
|
||||||
},
|
},
|
||||||
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 });
|
||||||
return loan;
|
return loan;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
||||||
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
|
const loan = await prisma.loan.findUnique({
|
||||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
where: { id },
|
||||||
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
|
include: {
|
||||||
|
installmentsList: true,
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
basicSalary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loan) {
|
||||||
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approverUser = await prisma.user.findUnique({
|
||||||
|
where: { id: approvedBy },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
include: {
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approverUser) {
|
||||||
|
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSystemAdmin = this.isSystemAdminUser(approverUser);
|
||||||
|
const basicSalary = Number(loan.employee?.basicSalary || 0);
|
||||||
|
const loanAmount = Number(loan.amount || 0);
|
||||||
|
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
||||||
|
|
||||||
|
// المرحلة الأولى: HR approval
|
||||||
|
if (loan.status === 'PENDING_HR') {
|
||||||
|
if (needsAdminApproval) {
|
||||||
|
const updatedLoan = await prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'PENDING_ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedLoan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// المرحلة الثانية: Admin approval إذا تجاوز 50%
|
||||||
|
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
|
||||||
|
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
|
||||||
|
}
|
||||||
|
|
||||||
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||||
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||||
let d = new Date(startDate);
|
let d = new Date(startDate);
|
||||||
|
|
||||||
for (let i = 1; i <= loan.installments; i++) {
|
for (let i = 1; i <= loan.installments; i++) {
|
||||||
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
|
installments.push({
|
||||||
|
installmentNumber: i,
|
||||||
|
dueDate: new Date(d),
|
||||||
|
amount: monthlyAmount,
|
||||||
|
});
|
||||||
d.setMonth(d.getMonth() + 1);
|
d.setMonth(d.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.loan.update({
|
prisma.loan.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
|
data: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
approvedBy,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
...installments.map((inst) =>
|
...installments.map((inst) =>
|
||||||
prisma.loanInstallment.create({
|
prisma.loanInstallment.create({
|
||||||
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
|
data: {
|
||||||
|
loanId: id,
|
||||||
|
installmentNumber: inst.installmentNumber,
|
||||||
|
dueDate: inst.dueDate,
|
||||||
|
amount: inst.amount,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
return this.findLoanById(id);
|
return this.findLoanById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const existing = await prisma.loan.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
const loan = await prisma.loan.update({
|
const loan = await prisma.loan.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'REJECTED', rejectedReason },
|
data: { status: 'REJECTED', rejectedReason },
|
||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
|
||||||
return loan;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||||
await prisma.loanInstallment.update({
|
await prisma.loanInstallment.update({
|
||||||
where: { id: installmentId },
|
where: { id: installmentId },
|
||||||
@@ -752,14 +1121,39 @@ class HRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||||
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
|
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
|
||||||
|
|
||||||
|
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||||
|
throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
|
||||||
|
}
|
||||||
|
|
||||||
const ent = await prisma.leaveEntitlement.upsert({
|
const ent = await prisma.leaveEntitlement.upsert({
|
||||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
|
where: {
|
||||||
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
|
employeeId_year_leaveType: {
|
||||||
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver || 0,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver ?? undefined,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||||
return ent;
|
return ent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== EMPLOYEE CONTRACTS ==========
|
// ========== EMPLOYEE CONTRACTS ==========
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,100 @@ export class PortalController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async getManagedLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const leaves = await portalService.getManagedLeaves(req.user?.employeeId, status);
|
||||||
|
res.json(ResponseFormatter.success(leaves));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const leave = await portalService.approveManagedLeave(req.user?.employeeId, req.params.id, req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(leave, 'تمت الموافقة على الإجازة من قبل مدير القسم - Leave approved by department manager'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const leave = await portalService.rejectManagedLeave(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
rejectedReason || '',
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة من قبل مدير القسم - Leave rejected by department manager'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getMyOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.getMyOvertimeRequests(req.user?.employeeId);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
date: req.body.date,
|
||||||
|
hours: req.body.hours,
|
||||||
|
reason: req.body.reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await portalService.submitOvertimeRequest(req.user?.employeeId, data, req.user!.id);
|
||||||
|
res.status(201).json(ResponseFormatter.success(result, 'تم إرسال طلب الساعات الإضافية'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.getManagedOvertimeRequests(req.user?.employeeId);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.approveManagedOvertimeRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.attendanceId,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تمت الموافقة على طلب الساعات الإضافية'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.rejectManagedOvertimeRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.attendanceId,
|
||||||
|
req.body.rejectedReason || '',
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم رفض طلب الساعات الإضافية'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ class PortalService {
|
|||||||
position: { select: { title: true, titleAr: true } },
|
position: { select: { title: true, titleAr: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
|
||||||
|
if (!employee) {
|
||||||
|
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||||
|
}
|
||||||
|
|
||||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
||||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||||
@@ -46,6 +49,66 @@ class PortalService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildOvertimeRequestNote(
|
||||||
|
hours: number,
|
||||||
|
reason: string,
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'REJECTED',
|
||||||
|
rejectedReason?: string
|
||||||
|
) {
|
||||||
|
const safeReason = String(reason || '').replace(/\|/g, '/').trim();
|
||||||
|
const safeRejectedReason = String(rejectedReason || '').replace(/\|/g, '/').trim();
|
||||||
|
|
||||||
|
let note = `OT_REQUEST|status=${status}|hours=${hours}|reason=${safeReason}`;
|
||||||
|
if (status === 'REJECTED' && safeRejectedReason) {
|
||||||
|
note += `|rejectedReason=${safeRejectedReason}`;
|
||||||
|
}
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOvertimeRequestNote(notes?: string | null) {
|
||||||
|
return !!notes && notes.startsWith('OT_REQUEST|');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseOvertimeRequestNote(notes?: string | null) {
|
||||||
|
if (!notes || !notes.startsWith('OT_REQUEST|')) return null;
|
||||||
|
|
||||||
|
const parts = notes.split('|');
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
const idx = part.indexOf('=');
|
||||||
|
if (idx > -1) {
|
||||||
|
const key = part.slice(0, idx);
|
||||||
|
const value = part.slice(idx + 1);
|
||||||
|
data[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data.status || 'PENDING',
|
||||||
|
hours: Number(data.hours || 0),
|
||||||
|
reason: data.reason || '',
|
||||||
|
rejectedReason: data.rejectedReason || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOvertimeRequest(attendance: any) {
|
||||||
|
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: attendance.id,
|
||||||
|
attendanceId: attendance.id,
|
||||||
|
date: attendance.date,
|
||||||
|
hours: parsed.hours || Number(attendance.overtimeHours || 0),
|
||||||
|
reason: parsed.reason,
|
||||||
|
status: parsed.status,
|
||||||
|
rejectedReason: parsed.rejectedReason || '',
|
||||||
|
createdAt: attendance.createdAt,
|
||||||
|
employee: attendance.employee,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getMyLoans(employeeId: string | undefined) {
|
async getMyLoans(employeeId: string | undefined) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return prisma.loan.findMany({
|
return prisma.loan.findMany({
|
||||||
@@ -55,7 +118,268 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
async getMyOvertimeRequests(employeeId: string | undefined) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const rows = await prisma.attendance.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: empId,
|
||||||
|
notes: {
|
||||||
|
startsWith: 'OT_REQUEST|',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc',
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => this.formatOvertimeRequest(row))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitOvertimeRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: { date: string; hours: number; reason: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
if (!data.date) {
|
||||||
|
throw new AppError(400, 'تاريخ الساعات الإضافية مطلوب');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.hours || Number(data.hours) <= 0) {
|
||||||
|
throw new AppError(400, 'عدد الساعات غير صالح');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.reason || !data.reason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDate = new Date(data.date);
|
||||||
|
const note = this.buildOvertimeRequestNote(Number(data.hours), data.reason.trim(), 'PENDING');
|
||||||
|
|
||||||
|
const existing = await prisma.attendance.findFirst({
|
||||||
|
where: {
|
||||||
|
employeeId: empId,
|
||||||
|
date: requestDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let attendance;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
attendance = await prisma.attendance.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
overtimeHours: Number(data.hours),
|
||||||
|
notes: note,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
attendance = await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
employeeId: empId,
|
||||||
|
date: requestDate,
|
||||||
|
status: 'PRESENT',
|
||||||
|
overtimeHours: Number(data.hours),
|
||||||
|
notes: note,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatOvertimeRequest(attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const rows = await prisma.attendance.findMany({
|
||||||
|
where: {
|
||||||
|
notes: {
|
||||||
|
startsWith: 'OT_REQUEST|',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc',
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => this.formatOvertimeRequest(row))
|
||||||
|
.filter((row: any) => row && row.status === 'PENDING');
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedOvertimeRequest(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
attendanceId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
const attendance = await prisma.attendance.findUnique({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attendance) {
|
||||||
|
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOvertimeRequestNote(attendance.notes)) {
|
||||||
|
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||||
|
if (!parsed || parsed.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNote = this.buildOvertimeRequestNote(parsed.hours, parsed.reason, 'APPROVED');
|
||||||
|
|
||||||
|
const updated = await prisma.attendance.update({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
data: {
|
||||||
|
overtimeHours: parsed.hours,
|
||||||
|
notes: updatedNote,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatOvertimeRequest(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async rejectManagedOvertimeRequest(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
attendanceId: string,
|
||||||
|
rejectedReason: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب الرفض مطلوب');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendance = await prisma.attendance.findUnique({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attendance) {
|
||||||
|
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOvertimeRequestNote(attendance.notes)) {
|
||||||
|
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = this.parseOvertimeRequestNote(attendance.notes);
|
||||||
|
if (!parsed || parsed.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNote = this.buildOvertimeRequestNote(
|
||||||
|
parsed.hours,
|
||||||
|
parsed.reason,
|
||||||
|
'REJECTED',
|
||||||
|
rejectedReason.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await prisma.attendance.update({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
data: {
|
||||||
|
notes: updatedNote,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatOvertimeRequest(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async submitLoanRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: { type: string; amount: number; installments?: number; reason?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
@@ -75,7 +399,31 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
|
async getManagedLeaves(employeeId: string | undefined, status?: string) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
return hrService.findManagedLeaves(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedLeave(employeeId: string | undefined, leaveId: string, userId: string) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
return hrService.managerApproveLeave(leaveId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedLeave(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
leaveId: string,
|
||||||
|
rejectedReason: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
return hrService.managerRejectLeave(leaveId, rejectedReason, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitLeaveRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: { leaveType: string; startDate: Date; endDate: Date; reason?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
@@ -88,7 +436,11 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
|
async submitPurchaseRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: { items: any[]; reason?: string; priority?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const MODULES = [
|
|||||||
{ 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: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave 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' },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const MODULES = [
|
|||||||
{ 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: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave 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' },
|
||||||
|
|||||||
@@ -143,16 +143,14 @@ function DashboardContent() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary-600 p-2 rounded-lg">
|
|
||||||
<Image
|
<Image
|
||||||
src={logoImage}
|
src={logoImage}
|
||||||
alt="Company Logo"
|
alt="Company Logo"
|
||||||
width={32}
|
width={48}
|
||||||
height={32}
|
height={48}
|
||||||
className="h-8 w-8 object-contain"
|
className="object-contain"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
||||||
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||||
|
|||||||
@@ -372,8 +372,8 @@ function HRContent() {
|
|||||||
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
|
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
|
||||||
setLeavesData(leaves)
|
setLeavesData(leaves)
|
||||||
} else if (activeTab === 'loans') {
|
} else if (activeTab === 'loans') {
|
||||||
const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 })
|
const { loans } = await hrAdminAPI.getLoans({ pageSize: 50 })
|
||||||
setLoansData(loans)
|
setLoansData(loans.filter((loan: any) => ['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)))
|
||||||
} else if (activeTab === 'purchases') {
|
} else if (activeTab === 'purchases') {
|
||||||
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
|
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
|
||||||
setPurchasesData(purchaseRequests)
|
setPurchasesData(purchaseRequests)
|
||||||
@@ -1185,19 +1185,75 @@ function HRContent() {
|
|||||||
<p className="text-gray-500">No pending loans</p>
|
<p className="text-gray-500">No pending loans</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{loansData.map((l: any) => (
|
{loansData.map((l: any) => {
|
||||||
|
const salary = Number(l.employee?.basicSalary || 0)
|
||||||
|
const amount = Number(l.amount || 0)
|
||||||
|
const needsAdmin = salary > 0 && amount > salary * 0.5
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
||||||
<p className="text-sm text-gray-600">{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)</p>
|
<p className="text-sm text-gray-600">
|
||||||
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
|
{l.loanNumber} - {l.type} - {amount.toLocaleString()} SAR ({l.installments} installments)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الراتب الأساسي: {salary.toLocaleString()} SAR
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الحالة: {l.status === 'PENDING_HR' ? 'بانتظار HR' : 'بانتظار مدير النظام'}
|
||||||
|
</p>
|
||||||
|
{needsAdmin && (
|
||||||
|
<p className="text-xs text-orange-600">
|
||||||
|
هذا الطلب يتجاوز 50% من الراتب الأساسي ويحتاج موافقة مدير النظام
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{l.reason && <p className="text-xs text-gray-500 mt-1">{l.reason}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={async () => { try { await hrAdminAPI.approveLoan(l.id); toast.success('Approved'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
|
<button
|
||||||
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLoan(l.id, r); toast.success('Rejected'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const response = await hrAdminAPI.approveLoan(l.id)
|
||||||
|
const updatedLoan = response?.data?.data || response?.data || response
|
||||||
|
|
||||||
|
if (updatedLoan?.status === 'PENDING_ADMIN') {
|
||||||
|
toast.success('تمت موافقة HR وتحويل الطلب إلى مدير النظام')
|
||||||
|
} else {
|
||||||
|
toast.success('Approved')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const r = prompt('Rejection reason?')
|
||||||
|
if (r) {
|
||||||
|
try {
|
||||||
|
await hrAdminAPI.rejectLoan(l.id, r)
|
||||||
|
toast.success('Rejected')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,18 +13,31 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Building2,
|
Building2,
|
||||||
LogOut,
|
LogOut,
|
||||||
User
|
User,
|
||||||
|
CheckCircle2,
|
||||||
|
TimerReset,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
||||||
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
||||||
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
||||||
{ icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
|
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
|
||||||
|
...(hasPermission('department_overtime_requests', 'view')
|
||||||
|
? [{
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'طلبات الساعات الإضافية',
|
||||||
|
labelEn: 'Department Overtime Requests',
|
||||||
|
href: '/portal/managed-overtime-requests'
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(hasPermission('department_leave_requests', 'view')
|
||||||
|
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
|
||||||
|
: []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
|
||||||
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
|
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
|
||||||
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { portalAPI } from '@/lib/api/portal'
|
|||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { Calendar, Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
const LEAVE_TYPES = [
|
const LEAVE_TYPES = [
|
||||||
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
||||||
{ value: 'SICK', label: 'إجازة مرضية' },
|
{ value: 'HOURLY', label: 'إجازة ساعية' },
|
||||||
{ value: 'EMERGENCY', label: 'طوارئ' },
|
|
||||||
{ value: 'UNPAID', label: 'بدون راتب' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
@@ -26,7 +24,16 @@ export default function PortalLeavePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -43,24 +50,54 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
let payload: any = {
|
||||||
|
leaveType: form.leaveType,
|
||||||
|
reason: form.reason || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.leaveType === 'ANNUAL') {
|
||||||
if (!form.startDate || !form.endDate) {
|
if (!form.startDate || !form.endDate) {
|
||||||
toast.error('أدخل تاريخ البداية والنهاية')
|
toast.error('أدخل تاريخ البداية والنهاية')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date(form.endDate) < new Date(form.startDate)) {
|
if (new Date(form.endDate) < new Date(form.startDate)) {
|
||||||
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
|
toast.error('تاريخ النهاية يجب أن يكون بعد البداية')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
payload.startDate = form.startDate
|
||||||
|
payload.endDate = form.endDate
|
||||||
|
} else {
|
||||||
|
if (!form.leaveDate || !form.startTime || !form.endTime) {
|
||||||
|
toast.error('أدخل التاريخ والوقت للإجازة الساعية')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.startTime >= form.endTime) {
|
||||||
|
toast.error('وقت النهاية يجب أن يكون بعد البداية')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
|
||||||
|
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitLeaveRequest({
|
|
||||||
leaveType: form.leaveType,
|
portalAPI.submitLeaveRequest(payload)
|
||||||
startDate: form.startDate,
|
|
||||||
endDate: form.endDate,
|
|
||||||
reason: form.reason || undefined,
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
|
setForm({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
toast.success('تم إرسال طلب الإجازة')
|
toast.success('تم إرسال طلب الإجازة')
|
||||||
load()
|
load()
|
||||||
})
|
})
|
||||||
@@ -72,6 +109,8 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||||
<button
|
<button
|
||||||
@@ -83,6 +122,7 @@ export default function PortalLeavePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* الرصيد */}
|
||||||
{leaveBalance.length > 0 && (
|
{leaveBalance.length > 0 && (
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
|
||||||
@@ -90,36 +130,42 @@ export default function PortalLeavePage() {
|
|||||||
{leaveBalance.map((b) => (
|
{leaveBalance.map((b) => (
|
||||||
<div key={b.leaveType} className="border rounded-lg p-4">
|
<div key={b.leaveType} className="border rounded-lg p-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
|
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
|
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
|
||||||
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* الطلبات */}
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
|
||||||
|
|
||||||
{leaves.length === 0 ? (
|
{leaves.length === 0 ? (
|
||||||
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
|
<p className="text-gray-500 text-center py-8">لا توجد طلبات</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{leaves.map((l) => {
|
{leaves.map((l) => {
|
||||||
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
|
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم
|
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
|
||||||
|
{l.leaveType === 'HOURLY'
|
||||||
|
? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
: `${l.days} يوم`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
|
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
|
||||||
</p>
|
</p>
|
||||||
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,59 +175,116 @@ export default function PortalLeavePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* الفورم */}
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
|
{/* نوع الإجازة */}
|
||||||
<select
|
<select
|
||||||
value={form.leaveType}
|
value={form.leaveType}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
setForm({
|
||||||
|
leaveType: e.target.value,
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{LEAVE_TYPES.map((t) => (
|
{LEAVE_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
{/* سنوية */}
|
||||||
|
{form.leaveType === 'ANNUAL' ? (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
|
<label className="text-sm">من تاريخ</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.startDate}
|
value={form.startDate}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
|
onChange={(e) => setForm(p => ({ ...p, startDate: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="border p-2 rounded w-full"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
|
<label className="text-sm">إلى تاريخ</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.endDate}
|
value={form.endDate}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
|
onChange={(e) => setForm(p => ({ ...p, endDate: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="border p-2 rounded w-full"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ساعية */
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
|
<label className="text-sm">التاريخ</label>
|
||||||
<textarea
|
<input
|
||||||
value={form.reason}
|
type="date"
|
||||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
value={form.leaveDate}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
onChange={(e) => setForm(p => ({ ...p, leaveDate: e.target.value }))}
|
||||||
rows={3}
|
className="border p-2 rounded w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm">من الساعة</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={form.startTime}
|
||||||
|
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
|
||||||
|
className="border p-2 rounded w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm">إلى الساعة</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={form.endTime}
|
||||||
|
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
|
||||||
|
className="border p-2 rounded w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* السبب */}
|
||||||
|
<textarea
|
||||||
|
placeholder="اكتب سبب الإجازة..."
|
||||||
|
value={form.reason}
|
||||||
|
onChange={(e) => setForm(p => ({ ...p, reason: e.target.value }))}
|
||||||
|
className="w-full border p-2 rounded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* أزرار */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { toast } from 'react-hot-toast'
|
|||||||
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
|
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
PENDING_HR: { label: 'بانتظار موافقة الموارد البشرية', color: 'bg-amber-100 text-amber-800' },
|
||||||
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
|
PENDING_ADMIN: { label: 'بانتظار موافقة مدير النظام', color: 'bg-orange-100 text-orange-800' },
|
||||||
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||||
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
|
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
|
||||||
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
|
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
|
||||||
@@ -32,16 +32,23 @@ export default function PortalLoansPage() {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const amount = parseFloat(form.amount)
|
const amount = parseFloat(form.amount)
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
toast.error('أدخل مبلغاً صالحاً')
|
toast.error('أدخل مبلغاً صالحاً')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
toast.error('سبب القرض مطلوب')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitLoanRequest({
|
portalAPI.submitLoanRequest({
|
||||||
type: form.type,
|
type: form.type,
|
||||||
amount,
|
amount,
|
||||||
installments: parseInt(form.installments) || 1,
|
installments: parseInt(form.installments) || 1,
|
||||||
reason: form.reason || undefined,
|
reason: form.reason.trim(),
|
||||||
})
|
})
|
||||||
.then((loan) => {
|
.then((loan) => {
|
||||||
setLoans((prev) => [loan, ...prev])
|
setLoans((prev) => [loan, ...prev])
|
||||||
@@ -162,6 +169,7 @@ export default function PortalLoansPage() {
|
|||||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal file
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type ManagedLeave } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function ManagedLeavesPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canApproveDepartmentLeaveRequests = hasPermission('department_leave_requests', 'approve')
|
||||||
|
|
||||||
|
const fetchLeaves = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getManagedLeaves('PENDING')
|
||||||
|
setLeaves(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل طلبات الإجازات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canViewDepartmentLeaveRequests) {
|
||||||
|
fetchLeaves()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [canViewDepartmentLeaveRequests])
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setProcessingId(id)
|
||||||
|
await portalAPI.approveManagedLeave(id)
|
||||||
|
toast.success('تمت الموافقة على طلب الإجازة')
|
||||||
|
fetchLeaves()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل اعتماد طلب الإجازة')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
const rejectedReason = window.prompt('اكتب سبب الرفض')
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessingId(id)
|
||||||
|
await portalAPI.rejectManagedLeave(id, rejectedReason.trim())
|
||||||
|
toast.success('تم رفض طلب الإجازة')
|
||||||
|
fetchLeaves()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل رفض طلب الإجازة')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLeaveType = (leaveType: string) => {
|
||||||
|
if (leaveType === 'ANNUAL') return 'إجازة سنوية'
|
||||||
|
if (leaveType === 'HOURLY') return 'إجازة ساعية'
|
||||||
|
return leaveType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canViewDepartmentLeaveRequests) {
|
||||||
|
return <div className="text-center text-gray-500 py-12">الوصول مرفوض</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">طلبات إجازات القسم</h1>
|
||||||
|
<p className="text-gray-600 mt-1">اعتماد أو رفض طلبات موظفي القسم المباشرين</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/portal"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
العودة
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{leaves.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
لا توجد طلبات إجازات معلقة لموظفي القسم
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الموظف</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">نوع الإجازة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الفترة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">المدة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">السبب</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الإجراءات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{leaves.map((leave) => (
|
||||||
|
<tr key={leave.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{leave.employee.firstName} {leave.employee.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{leave.employee.uniqueEmployeeId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-900">
|
||||||
|
{formatLeaveType(leave.leaveType)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p>{new Date(leave.startDate).toLocaleString()}</p>
|
||||||
|
<p>{new Date(leave.endDate).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-900">
|
||||||
|
{leave.leaveType === 'HOURLY'
|
||||||
|
? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
: `${leave.days} يوم`}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-600 max-w-xs">
|
||||||
|
<p className="truncate" title={leave.reason || ''}>
|
||||||
|
{leave.reason || '-'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{canApproveDepartmentLeaveRequests ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(leave.id)}
|
||||||
|
disabled={processingId === leave.id}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
قبول
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(leave.id)}
|
||||||
|
disabled={processingId === leave.id}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
رفض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">عرض فقط</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal file
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { CheckCircle2, XCircle, User } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ManagedOvertimeRequestsPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const canView = hasPermission('department_overtime_requests', 'view')
|
||||||
|
const canApprove = hasPermission('department_overtime_requests', 'approve')
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getManagedOvertimeRequests()
|
||||||
|
setItems(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canView) {
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [canView])
|
||||||
|
|
||||||
|
const handleApprove = async (attendanceId: string) => {
|
||||||
|
try {
|
||||||
|
setProcessingId(attendanceId)
|
||||||
|
await portalAPI.approveManagedOvertimeRequest(attendanceId)
|
||||||
|
toast.success('تمت الموافقة')
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (attendanceId: string) => {
|
||||||
|
const rejectedReason = window.prompt('اكتب سبب الرفض')
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessingId(attendanceId)
|
||||||
|
await portalAPI.rejectManagedOvertimeRequest(attendanceId, rejectedReason.trim())
|
||||||
|
toast.success('تم الرفض')
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return <div className="text-center py-12 text-gray-500">الوصول مرفوض</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">طلبات الساعات الإضافية</h1>
|
||||||
|
<p className="text-gray-600 mt-1">طلبات موظفي القسم المباشرين</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-gray-500">لا توجد طلبات معلقة</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="p-6 flex items-start justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-gray-400" />
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{item.employee?.firstName} {item.employee?.lastName}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-500">{item.employee?.uniqueEmployeeId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
التاريخ: {new Date(item.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
|
||||||
|
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canApprove ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(item.attendanceId)}
|
||||||
|
disabled={processingId === item.attendanceId}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
قبول
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(item.attendanceId)}
|
||||||
|
disabled={processingId === item.attendanceId}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
رفض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">عرض فقط</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
frontend/src/app/portal/overtime/page.tsx
Normal file
191
frontend/src/app/portal/overtime/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { Plus, Clock3 } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
||||||
|
APPROVED: { label: 'مقبول', color: 'bg-green-100 text-green-800' },
|
||||||
|
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortalOvertimePage() {
|
||||||
|
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
date: '',
|
||||||
|
hours: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getOvertimeRequests()
|
||||||
|
setItems(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const hours = parseFloat(form.hours)
|
||||||
|
|
||||||
|
if (!form.date) {
|
||||||
|
toast.error('اختر التاريخ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hours || hours <= 0) {
|
||||||
|
toast.error('أدخل عدد ساعات صالح')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
toast.error('سبب الساعات الإضافية مطلوب')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
await portalAPI.submitOvertimeRequest({
|
||||||
|
date: form.date,
|
||||||
|
hours,
|
||||||
|
reason: form.reason.trim(),
|
||||||
|
})
|
||||||
|
toast.success('تم إرسال الطلب')
|
||||||
|
setOpen(false)
|
||||||
|
setForm({ date: '', hours: '', reason: '' })
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">الساعات الإضافية</h1>
|
||||||
|
<p className="text-gray-600 mt-1">إرسال ومتابعة طلبات الساعات الإضافية</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
إضافة طلب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-gray-500">لا توجد طلبات حتى الآن</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{items.map((item) => {
|
||||||
|
const meta = STATUS_MAP[item.status] || STATUS_MAP.PENDING
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="p-6 flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock3 className="h-4 w-4 text-gray-500" />
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{new Date(item.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
|
||||||
|
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
|
||||||
|
{item.rejectedReason ? (
|
||||||
|
<p className="text-sm text-red-600">سبب الرفض: {item.rejectedReason}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">عدد الساعات</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
step="0.5"
|
||||||
|
value={form.hours}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, hours: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">السبب</label>
|
||||||
|
<textarea
|
||||||
|
value={form.reason}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
|
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export default function PortalDashboardPage() {
|
export default function PortalDashboardPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
const [data, setData] = useState<PortalProfile | null>(null)
|
const [data, setData] = useState<PortalProfile | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ export default function PortalDashboardPage() {
|
|||||||
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
|
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
|
||||||
|
|
||||||
const { employee, stats } = data
|
const { employee, stats } = data
|
||||||
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
|
||||||
const name = employee.firstNameAr && employee.lastNameAr
|
const name = employee.firstNameAr && employee.lastNameAr
|
||||||
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
||||||
: `${employee.firstName} ${employee.lastName}`
|
: `${employee.firstName} ${employee.lastName}`
|
||||||
@@ -35,7 +39,7 @@ export default function PortalDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className={`grid grid-cols-1 md:grid-cols-2 ${canViewDepartmentLeaveRequests ? 'lg:grid-cols-5' : 'lg:grid-cols-4'} gap-6`}>
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,6 +57,23 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canViewDepartmentLeaveRequests && (
|
||||||
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">طلبات إجازات القسم</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">مراجعة واعتماد</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-100 p-3 rounded-lg">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/portal/managed-leaves" className="mt-4 text-sm text-emerald-600 hover:underline flex items-center gap-1">
|
||||||
|
عرض الطلبات <ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -118,7 +139,9 @@ export default function PortalDashboardPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{stats.leaveBalance.map((b) => (
|
{stats.leaveBalance.map((b) => (
|
||||||
<tr key={b.leaveType} className="border-b last:border-0">
|
<tr key={b.leaveType} className="border-b last:border-0">
|
||||||
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
|
<td className="py-2">
|
||||||
|
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'HOURLY' ? 'ساعية' : b.leaveType}
|
||||||
|
</td>
|
||||||
<td className="py-2">{b.totalDays + b.carriedOver}</td>
|
<td className="py-2">{b.totalDays + b.carriedOver}</td>
|
||||||
<td className="py-2">{b.usedDays}</td>
|
<td className="py-2">{b.usedDays}</td>
|
||||||
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
|
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
|
||||||
@@ -131,24 +154,24 @@ export default function PortalDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Link
|
<Link href="/portal/loans" className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700">
|
||||||
href="/portal/loans"
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب قرض
|
طلب قرض
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/portal/leave"
|
<Link href="/portal/leave" className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700">
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب إجازة
|
طلب إجازة
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/portal/purchase-requests"
|
{canViewDepartmentLeaveRequests && (
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
<Link href="/portal/managed-leaves" className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">
|
||||||
>
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
طلبات إجازات القسم
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب شراء
|
طلب شراء
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -66,6 +66,24 @@ export interface Leave {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortalOvertimeRequest {
|
||||||
|
id: string
|
||||||
|
attendanceId: string
|
||||||
|
date: string
|
||||||
|
hours: number
|
||||||
|
reason: string
|
||||||
|
status: string
|
||||||
|
rejectedReason?: string
|
||||||
|
createdAt: string
|
||||||
|
employee?: {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
uniqueEmployeeId: string
|
||||||
|
reportingToId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseRequest {
|
export interface PurchaseRequest {
|
||||||
id: string
|
id: string
|
||||||
requestNumber: string
|
requestNumber: string
|
||||||
@@ -90,6 +108,24 @@ export interface Attendance {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagedLeave {
|
||||||
|
id: string
|
||||||
|
leaveType: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
days: number
|
||||||
|
status: string
|
||||||
|
reason?: string
|
||||||
|
rejectedReason?: string
|
||||||
|
employee: {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
uniqueEmployeeId: string
|
||||||
|
reportingToId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface Salary {
|
export interface Salary {
|
||||||
id: string
|
id: string
|
||||||
month: number
|
month: number
|
||||||
@@ -106,6 +142,24 @@ export interface Salary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const portalAPI = {
|
export const portalAPI = {
|
||||||
|
|
||||||
|
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (status && status !== 'all') q.append('status', status)
|
||||||
|
const response = await api.get(`/hr/portal/managed-leaves?${q.toString()}`)
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
approveManagedLeave: async (id: string) => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-leaves/${id}/approve`)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectManagedLeave: async (id: string, rejectedReason: string) => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-leaves/${id}/reject`, { rejectedReason })
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
getMe: async (): Promise<PortalProfile> => {
|
getMe: async (): Promise<PortalProfile> => {
|
||||||
const response = await api.get('/hr/portal/me')
|
const response = await api.get('/hr/portal/me')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
@@ -121,6 +175,40 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
|
const response = await api.get('/hr/portal/overtime-requests')
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
submitOvertimeRequest: async (data: {
|
||||||
|
date: string
|
||||||
|
hours: number
|
||||||
|
reason: string
|
||||||
|
}): Promise<PortalOvertimeRequest> => {
|
||||||
|
const response = await api.post('/hr/portal/overtime-requests', data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
|
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
approveManagedOvertimeRequest: async (attendanceId: string): Promise<PortalOvertimeRequest> => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/approve`)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectManagedOvertimeRequest: async (
|
||||||
|
attendanceId: string,
|
||||||
|
rejectedReason: string
|
||||||
|
): Promise<PortalOvertimeRequest> => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/reject`, {
|
||||||
|
rejectedReason,
|
||||||
|
})
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
|
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
|
||||||
const params = year ? `?year=${year}` : ''
|
const params = year ? `?year=${year}` : ''
|
||||||
const response = await api.get(`/hr/portal/leave-balance${params}`)
|
const response = await api.get(`/hr/portal/leave-balance${params}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user