update HR modules

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

View File

@@ -14,6 +14,46 @@ router.post('/portal/loans', portalController.submitLoanRequest);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance); router.get('/portal/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;

View File

@@ -313,40 +313,54 @@ class HRService {
// ========== LEAVES ========== // ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) { async createLeaveRequest(data: any, userId: string) {
const days = this.calculateLeaveDays(data.startDate, data.endDate); const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const ent = await prisma.leaveEntitlement.findUnique({ if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } }, throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
const leave = await prisma.leave.create({
data: {
...data,
days,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
return leave;
} }
const normalizedLeaveType = String(data.leaveType).toUpperCase();
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const ent = await prisma.leaveEntitlement.findUnique({
where: {
employeeId_year_leaveType: {
employeeId: data.employeeId,
year,
leaveType: normalizedLeaveType,
},
},
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
const leave = await prisma.leave.create({
data: {
...data,
leaveType: normalizedLeaveType,
days,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
return leave;
}
async approveLeave(id: string, approvedBy: string, userId: string) { 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 } },
@@ -534,94 +737,260 @@ class HRService {
return `${prefix}${next.toString().padStart(4, '0')}`; return `${prefix}${next.toString().padStart(4, '0')}`;
} }
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) { async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
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([
prisma.loan.count({ where }), const [total, loans] = await Promise.all([
prisma.loan.findMany({ prisma.loan.count({ where }),
where, prisma.loan.findMany({
skip, where,
take: pageSize, skip,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true }, take: pageSize,
orderBy: { createdAt: 'desc' }, include: {
}), employee: {
]); select: {
return { loans, total, page, pageSize }; id: true,
} firstName: true,
lastName: true,
uniqueEmployeeId: true,
basicSalary: true,
position: {
select: {
code: true,
title: true,
titleAr: true,
},
},
},
},
installmentsList: true,
},
orderBy: { createdAt: 'desc' },
}),
]);
return { loans, total, page, pageSize };
}
async findLoanById(id: string) { 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: {
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found'); select: {
return loan; id: true,
} firstName: true,
lastName: true,
uniqueEmployeeId: true,
basicSalary: true,
position: {
select: {
code: true,
title: true,
titleAr: true,
},
},
},
},
installmentsList: { orderBy: { installmentNumber: 'asc' } },
},
});
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
return loan;
}
private isSystemAdminUser(user: any) {
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
const positionTitleAr = user?.employee?.position?.titleAr || '';
return (
positionCode === 'SYS_ADMIN' ||
positionCode === 'SYSTEM_ADMIN' ||
positionTitle === 'SYSTEM ADMINISTRATOR' ||
positionTitleAr === 'مدير النظام'
);
}
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) { async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const loanNumber = await this.generateLoanNumber(); if (!data.reason || !data.reason.trim()) {
const installments = data.installments || 1; throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason,
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
} }
if (!data.amount || Number(data.amount) <= 0) {
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
}
const loanNumber = await this.generateLoanNumber();
const installments = data.installments || 1;
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason.trim(),
status: 'PENDING_HR',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
}
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) { 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,
},
},
},
});
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments; if (!loan) {
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = []; throw new AppError(404, 'القرض غير موجود - Loan not found');
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
})
),
]);
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
return this.findLoanById(id);
} }
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
}
const approverUser = await prisma.user.findUnique({
where: { id: approvedBy },
include: {
employee: {
include: {
position: true,
},
},
},
});
if (!approverUser) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const isSystemAdmin = this.isSystemAdminUser(approverUser);
const basicSalary = Number(loan.employee?.basicSalary || 0);
const loanAmount = Number(loan.amount || 0);
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
// المرحلة الأولى: HR approval
if (loan.status === 'PENDING_HR') {
if (needsAdminApproval) {
const updatedLoan = await prisma.loan.update({
where: { id },
data: {
status: 'PENDING_ADMIN',
},
});
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
userId,
});
return updatedLoan;
}
}
// المرحلة الثانية: Admin approval إذا تجاوز 50%
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
}
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({
installmentNumber: i,
dueDate: new Date(d),
amount: monthlyAmount,
});
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: {
status: 'ACTIVE',
approvedBy,
approvedAt: new Date(),
startDate,
endDate,
},
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: {
loanId: id,
installmentNumber: inst.installmentNumber,
dueDate: inst.dueDate,
amount: inst.amount,
status: 'PENDING',
},
})
),
]);
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
userId,
});
return this.findLoanById(id);
}
async rejectLoan(id: string, rejectedReason: string, userId: string) { async rejectLoan(id: string, rejectedReason: string, userId: string) {
const loan = await prisma.loan.update({ const existing = await prisma.loan.findUnique({ where: { id } });
where: { id },
data: { status: 'REJECTED', rejectedReason }, if (!existing) {
include: { employee: true }, throw new AppError(404, 'القرض غير موجود - Loan not found');
});
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
return loan;
} }
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
}
const loan = await prisma.loan.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
await prisma.loanInstallment.update({ await prisma.loanInstallment.update({
where: { id: installmentId }, where: { id: installmentId },
@@ -751,16 +1120,41 @@ class HRService {
}); });
} }
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) { async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
const ent = await prisma.leaveEntitlement.upsert({ const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } }, const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes }, if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
}); throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
} }
const ent = await prisma.leaveEntitlement.upsert({
where: {
employeeId_year_leaveType: {
employeeId: data.employeeId,
year: data.year,
leaveType: normalizedLeaveType,
},
},
create: {
employeeId: data.employeeId,
year: data.year,
leaveType: normalizedLeaveType,
totalDays: data.totalDays,
carriedOver: data.carriedOver || 0,
notes: data.notes,
},
update: {
totalDays: data.totalDays,
carriedOver: data.carriedOver ?? undefined,
notes: data.notes,
},
});
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
}
// ========== EMPLOYEE CONTRACTS ========== // ========== EMPLOYEE CONTRACTS ==========
private async generateContractNumber(): Promise<string> { private async generateContractNumber(): Promise<string> {

View File

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

View File

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

View File

@@ -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' },

View File

@@ -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' },

View File

@@ -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={48}
width={32} height={48}
height={32} className="object-contain"
className="h-8 w-8 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>

View File

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

View File

@@ -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' },
] ]

View File

@@ -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()
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية') let payload: any = {
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
return
}
setSubmitting(true)
portalAPI.submitLeaveRequest({
leaveType: form.leaveType, leaveType: form.leaveType,
startDate: form.startDate,
endDate: form.endDate,
reason: form.reason || undefined, reason: form.reason || undefined,
}) }
if (form.leaveType === 'ANNUAL') {
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = form.startDate
payload.endDate = form.endDate
} else {
if (!form.leaveDate || !form.startTime || !form.endTime) {
toast.error('أدخل التاريخ والوقت للإجازة الساعية')
return
}
if (form.startTime >= form.endTime) {
toast.error('وقت النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
}
setSubmitting(true)
portalAPI.submitLeaveRequest(payload)
.then(() => { .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,
{LEAVE_TYPES.map((t) => ( startDate: '',
<option key={t.value} value={t.value}>{t.label}</option> endDate: '',
))} leaveDate: '',
</select> startTime: '',
</div> endTime: '',
<div className="grid grid-cols-2 gap-4"> reason: '',
<div> })
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label> }
<input className="w-full px-3 py-2 border rounded-lg"
type="date" >
value={form.startDate} {LEAVE_TYPES.map((t) => (
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))} <option key={t.value} value={t.value}>{t.label}</option>
className="w-full px-3 py-2 border border-gray-300 rounded-lg" ))}
required </select>
/>
{/* سنوية */}
{form.leaveType === 'ANNUAL' ? (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm(p => ({ ...p, startDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm(p => ({ ...p, endDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
</div> </div>
<div> ) : (
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label> /* ساعية */
<input <div className="grid grid-cols-3 gap-4">
type="date" <div>
value={form.endDate} <label className="text-sm">التاريخ</label>
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))} <input
className="w-full px-3 py-2 border border-gray-300 rounded-lg" type="date"
required value={form.leaveDate}
/> onChange={(e) => setForm(p => ({ ...p, leaveDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</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> </div>
</div> )}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label> {/* السبب */}
<textarea <textarea
value={form.reason} placeholder="اكتب سبب الإجازة..."
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))} value={form.reason}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" onChange={(e) => setForm(p => ({ ...p, reason: e.target.value }))}
rows={3} className="w-full border p-2 rounded"
/> />
</div>
{/* أزرار */}
<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>

View File

@@ -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,17 +32,24 @@ 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])
setShowModal(false) setShowModal(false)
@@ -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">

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

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

View File

@@ -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}`)