feat(hr): Complete HR module with Employee Portal, Loans, Leave, Purchase Requests, Contracts

- Database: Add Loan, LoanInstallment, PurchaseRequest, LeaveEntitlement, EmployeeContract models
- Database: Extend Attendance with ZK Tico fields (sourceDeviceId, externalId, rawData)
- Database: Add Employee.attendancePin for device mapping
- Backend: HR admin - Loans, Purchase Requests, Leave entitlements, Employee contracts CRUD
- Backend: Leave reject, bulk attendance sync (ZK Tico ready)
- Backend: Employee Portal API - scoped by employeeId (loans, leaves, purchase-requests, attendance, salaries)
- Frontend: Employee Portal - dashboard, loans, leave, purchase-requests, attendance, salaries
- Frontend: HR Admin - new tabs for Leaves, Loans, Purchase Requests, Contracts (approve/reject)
- Dashboard: Add My Portal link
- No destructive schema changes; additive migrations only

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-04 19:44:09 +04:00
parent ae890ca1c5
commit 72ed9a2ff5
18 changed files with 2649 additions and 8 deletions

View File

@@ -98,6 +98,16 @@ export class HRController {
}
}
async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { deviceId, records } = req.body;
const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id);
res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced'));
} catch (error) {
next(error);
}
}
// ========== LEAVES ==========
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
@@ -118,6 +128,29 @@ export class HRController {
}
}
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
} catch (error) {
next(error);
}
}
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
// ========== SALARIES ==========
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
@@ -187,6 +220,198 @@ export class HRController {
next(error);
}
}
// ========== LOANS ==========
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.findLoanById(req.params.id);
res.json(ResponseFormatter.success(loan));
} catch (error) {
next(error);
}
}
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.createLoan(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
} catch (error) {
next(error);
}
}
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { startDate } = req.body;
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
} catch (error) {
next(error);
}
}
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
} catch (error) {
next(error);
}
}
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { installmentId, paidDate } = req.body;
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
} catch (error) {
next(error);
}
}
// ========== PURCHASE REQUESTS ==========
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.findPurchaseRequestById(req.params.id);
res.json(ResponseFormatter.success(pr));
} catch (error) {
next(error);
}
}
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
} catch (error) {
next(error);
}
}
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
} catch (error) {
next(error);
}
}
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
} catch (error) {
next(error);
}
}
// ========== LEAVE ENTITLEMENTS ==========
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.params.employeeId || req.query.employeeId as string;
const year = parseInt(req.query.year as string) || new Date().getFullYear();
const balance = await hrService.getLeaveBalance(employeeId, year);
res.json(ResponseFormatter.success(balance));
} catch (error) {
next(error);
}
}
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.query.employeeId as string | undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
try {
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
} catch (error) {
next(error);
}
}
// ========== EMPLOYEE CONTRACTS ==========
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const c = await hrService.findEmployeeContractById(req.params.id);
res.json(ResponseFormatter.success(c));
} catch (error) {
next(error);
}
}
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.createEmployeeContract(data, req.user!.id);
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
} catch (error) {
next(error);
}
}
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
} catch (error) {
next(error);
}
}
}
export const hrController = new HRController();

View File

@@ -1,12 +1,24 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { hrController } from './hr.controller';
import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router();
router.use(authenticate);
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
router.get('/portal/me', portalController.getMe);
router.get('/portal/loans', portalController.getMyLoans);
router.post('/portal/loans', portalController.submitLoanRequest);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest);
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries);
// ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
@@ -19,11 +31,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
// ========== LEAVES ==========
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
// ========== SALARIES ==========
@@ -41,5 +56,35 @@ router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
// ========== LOANS ==========
router.get('/loans', authorize('hr', 'all', 'read'), hrController.findAllLoans);
router.get('/loans/:id', authorize('hr', 'all', 'read'), hrController.findLoanById);
router.post('/loans', authorize('hr', 'all', 'create'), hrController.createLoan);
router.post('/loans/:id/approve', authorize('hr', 'all', 'approve'), hrController.approveLoan);
router.post('/loans/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectLoan);
router.post('/loans/:id/pay-installment', authorize('hr', 'all', 'update'), hrController.recordLoanInstallmentPayment);
// ========== PURCHASE REQUESTS ==========
router.get('/purchase-requests', authorize('hr', 'all', 'read'), hrController.findAllPurchaseRequests);
router.get('/purchase-requests/:id', authorize('hr', 'all', 'read'), hrController.findPurchaseRequestById);
router.post('/purchase-requests', authorize('hr', 'all', 'create'), hrController.createPurchaseRequest);
router.post('/purchase-requests/:id/approve', authorize('hr', 'all', 'approve'), hrController.approvePurchaseRequest);
router.post('/purchase-requests/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectPurchaseRequest);
// ========== LEAVE ENTITLEMENTS ==========
router.get('/leave-balance/:employeeId', authorize('hr', 'all', 'read'), hrController.getLeaveBalance);
router.get('/leave-entitlements', authorize('hr', 'all', 'read'), hrController.findAllLeaveEntitlements);
router.post('/leave-entitlements', authorize('hr', 'all', 'create'), hrController.upsertLeaveEntitlement);
// ========== EMPLOYEE CONTRACTS ==========
router.get('/contracts', authorize('hr', 'all', 'read'), hrController.findAllEmployeeContracts);
router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEmployeeContractById);
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
export default router;

View File

@@ -227,12 +227,74 @@ class HRService {
async recordAttendance(data: any, userId: string) {
const attendance = await prisma.attendance.create({
data,
data: {
...data,
sourceDeviceId: data.sourceDeviceId ?? undefined,
externalId: data.externalId ?? undefined,
rawData: data.rawData ?? undefined,
},
});
return attendance;
}
async bulkSyncAttendanceFromDevice(deviceId: string, records: Array<{ employeePin: string; checkIn?: string; checkOut?: string; date: string }>, userId: string) {
const results: { created: number; updated: number; skipped: number } = { created: 0, updated: 0, skipped: 0 };
for (const rec of records) {
const emp = await prisma.employee.findFirst({
where: {
OR: [
{ attendancePin: rec.employeePin },
{ uniqueEmployeeId: rec.employeePin },
],
},
});
if (!emp) {
results.skipped++;
continue;
}
const date = new Date(rec.date);
const existing = await prisma.attendance.findUnique({
where: { employeeId_date: { employeeId: emp.id, date } },
});
const checkIn = rec.checkIn ? new Date(rec.checkIn) : null;
const checkOut = rec.checkOut ? new Date(rec.checkOut) : null;
const workHours = checkIn && checkOut ? (checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60) : null;
if (existing) {
await prisma.attendance.update({
where: { id: existing.id },
data: {
checkIn: checkIn ?? existing.checkIn,
checkOut: checkOut ?? existing.checkOut,
workHours: workHours ?? existing.workHours,
status: rec.checkIn ? 'PRESENT' : existing.status,
sourceDeviceId: deviceId,
externalId: `${deviceId}-${emp.id}-${rec.date}`,
rawData: rec as any,
},
});
results.updated++;
} else {
await prisma.attendance.create({
data: {
employeeId: emp.id,
date,
checkIn,
checkOut,
workHours,
status: rec.checkIn ? 'PRESENT' : 'ABSENT',
sourceDeviceId: deviceId,
externalId: `${deviceId}-${emp.id}-${rec.date}`,
rawData: rec as any,
},
});
results.created++;
}
}
await AuditLogger.log({ entityType: 'ATTENDANCE', entityId: deviceId, action: 'BULK_SYNC', userId, changes: results });
return results;
}
async getAttendance(employeeId: string, month: number, year: number) {
return prisma.attendance.findMany({
where: {
@@ -251,10 +313,24 @@ class HRService {
// ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) {
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
const leave = await prisma.leave.create({
data: {
...data,
days: this.calculateLeaveDays(data.startDate, data.endDate),
days,
},
include: {
employee: true,
@@ -278,12 +354,16 @@ class HRService {
status: 'APPROVED',
approvedBy,
approvedAt: new Date(),
rejectedReason: null,
},
include: {
employee: true,
},
});
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
@@ -294,6 +374,62 @@ class HRService {
return leave;
}
async rejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({ where: { id } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
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: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
return updated;
}
async findAllLeaves(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, leaves] = await Promise.all([
prisma.leave.count({ where }),
prisma.leave.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { createdAt: 'desc' },
}),
]);
return { leaves, total, page, pageSize };
}
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
});
if (ent) {
await prisma.leaveEntitlement.update({
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
data: { usedDays: { increment: days } },
});
}
}
// ========== SALARIES ==========
async processSalary(employeeId: string, month: number, year: number, userId: string) {
@@ -380,6 +516,323 @@ class HRService {
return salary;
}
// ========== LOANS ==========
private async generateLoanNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `LN-${year}-`;
const last = await prisma.loan.findFirst({
where: { loanNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { loanNumber: true },
});
let next = 1;
if (last) {
const parts = last.loanNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, loans] = await Promise.all([
prisma.loan.count({ where }),
prisma.loan.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
orderBy: { createdAt: 'desc' },
}),
]);
return { loans, total, page, pageSize };
}
async findLoanById(id: string) {
const loan = await prisma.loan.findUnique({
where: { id },
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
});
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
return loan;
}
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const loanNumber = await this.generateLoanNumber();
const installments = data.installments || 1;
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason,
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
}
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
})
),
]);
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
return this.findLoanById(id);
}
async rejectLoan(id: string, rejectedReason: string, userId: string) {
const loan = await prisma.loan.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
await prisma.loanInstallment.update({
where: { id: installmentId },
data: { status: 'PAID', paidDate },
});
const allPaid = (await prisma.loanInstallment.count({ where: { loanId, status: 'PENDING' } })) === 0;
if (allPaid) {
await prisma.loan.update({ where: { id: loanId }, data: { status: 'PAID_OFF' } });
}
await AuditLogger.log({ entityType: 'LOAN_INSTALLMENT', entityId: installmentId, action: 'PAY', userId });
return this.findLoanById(loanId);
}
// ========== PURCHASE REQUESTS ==========
private async generatePurchaseRequestNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `PR-${year}-`;
const last = await prisma.purchaseRequest.findFirst({
where: { requestNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { requestNumber: true },
});
let next = 1;
if (last) {
const parts = last.requestNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllPurchaseRequests(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, requests] = await Promise.all([
prisma.purchaseRequest.count({ where }),
prisma.purchaseRequest.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { createdAt: 'desc' },
}),
]);
return { purchaseRequests: requests, total, page, pageSize };
}
async findPurchaseRequestById(id: string) {
const req = await prisma.purchaseRequest.findUnique({ where: { id }, include: { employee: true } });
if (!req) throw new AppError(404, 'طلب الشراء غير موجود - Purchase request not found');
return req;
}
async createPurchaseRequest(data: { employeeId: string; items: any[]; reason?: string; priority?: string }, userId: string) {
const requestNumber = await this.generatePurchaseRequestNumber();
const totalAmount = Array.isArray(data.items)
? data.items.reduce((s: number, i: any) => s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)), 0)
: 0;
const req = await prisma.purchaseRequest.create({
data: {
requestNumber,
employeeId: data.employeeId,
items: data.items,
totalAmount,
reason: data.reason,
priority: data.priority || 'NORMAL',
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
return req;
}
async approvePurchaseRequest(id: string, approvedBy: string, userId: string) {
const req = await prisma.purchaseRequest.update({
where: { id },
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
return req;
}
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
const req = await prisma.purchaseRequest.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
return req;
}
// ========== LEAVE ENTITLEMENTS ==========
async getLeaveBalance(employeeId: string, year: number) {
const entitlements = await prisma.leaveEntitlement.findMany({
where: { employeeId, year },
});
const approvedLeaves = await prisma.leave.findMany({
where: { employeeId, status: 'APPROVED', startDate: { gte: new Date(year, 0, 1) }, endDate: { lte: new Date(year, 11, 31) } },
});
const usedByType: Record<string, number> = {};
for (const l of approvedLeaves) {
usedByType[l.leaveType] = (usedByType[l.leaveType] || 0) + l.days;
}
return entitlements.map((e) => ({
leaveType: e.leaveType,
totalDays: e.totalDays,
carriedOver: e.carriedOver,
usedDays: usedByType[e.leaveType] ?? e.usedDays,
available: e.totalDays + e.carriedOver - (usedByType[e.leaveType] ?? e.usedDays),
}));
}
async findAllLeaveEntitlements(employeeId?: string, year?: number) {
const where: any = {};
if (employeeId) where.employeeId = employeeId;
if (year) where.year = year;
return prisma.leaveEntitlement.findMany({
where,
include: { employee: { select: { id: true, firstName: true, lastName: true } } },
orderBy: [{ employeeId: 'asc' }, { year: 'desc' }, { leaveType: 'asc' } ],
});
}
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
const ent = await prisma.leaveEntitlement.upsert({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
});
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
}
// ========== EMPLOYEE CONTRACTS ==========
private async generateContractNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `ECT-${year}-`;
const last = await prisma.employeeContract.findFirst({
where: { contractNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { contractNumber: true },
});
let next = 1;
if (last) {
const parts = last.contractNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllEmployeeContracts(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, contracts] = await Promise.all([
prisma.employeeContract.count({ where }),
prisma.employeeContract.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { startDate: 'desc' },
}),
]);
return { contracts, total, page, pageSize };
}
async findEmployeeContractById(id: string) {
const c = await prisma.employeeContract.findUnique({ where: { id }, include: { employee: true } });
if (!c) throw new AppError(404, 'العقد غير موجود - Contract not found');
return c;
}
async createEmployeeContract(data: { employeeId: string; type: string; startDate: Date; endDate?: Date; salary: number; documentUrl?: string; notes?: string }, userId: string) {
const contractNumber = await this.generateContractNumber();
const contract = await prisma.employeeContract.create({
data: {
contractNumber,
employeeId: data.employeeId,
type: data.type,
startDate: data.startDate,
endDate: data.endDate,
salary: data.salary,
documentUrl: data.documentUrl,
notes: data.notes,
status: 'ACTIVE',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: contract.id, action: 'CREATE', userId });
return contract;
}
async updateEmployeeContract(id: string, data: { type?: string; endDate?: Date; salary?: number; documentUrl?: string; status?: string; notes?: string }, userId: string) {
const contract = await prisma.employeeContract.update({
where: { id },
data,
include: { employee: true },
});
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: id, action: 'UPDATE', userId });
return contract;
}
// ========== HELPERS ==========
private async generateEmployeeId(): Promise<string> {

View File

@@ -0,0 +1,106 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { portalService } from './portal.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class PortalController {
async getMe(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMe(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async getMyLoans(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loans = await portalService.getMyLoans(req.user?.employeeId);
res.json(ResponseFormatter.success(loans));
} catch (error) {
next(error);
}
}
async submitLoanRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await portalService.submitLoanRequest(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(loan, 'تم إرسال طلب القرض - Loan request submitted'));
} catch (error) {
next(error);
}
}
async getMyLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const balance = await portalService.getMyLeaveBalance(req.user?.employeeId, year);
res.json(ResponseFormatter.success(balance));
} catch (error) {
next(error);
}
}
async getMyLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const leaves = await portalService.getMyLeaves(req.user?.employeeId);
res.json(ResponseFormatter.success(leaves));
} catch (error) {
next(error);
}
}
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = {
...req.body,
startDate: new Date(req.body.startDate),
endDate: new Date(req.body.endDate),
};
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
} catch (error) {
next(error);
}
}
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const requests = await portalService.getMyPurchaseRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(requests));
} catch (error) {
next(error);
}
}
async submitPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await portalService.submitPurchaseRequest(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إرسال طلب الشراء - Purchase request submitted'));
} catch (error) {
next(error);
}
}
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const attendance = await portalService.getMyAttendance(req.user?.employeeId, month, year);
res.json(ResponseFormatter.success(attendance));
} catch (error) {
next(error);
}
}
async getMySalaries(req: AuthRequest, res: Response, next: NextFunction) {
try {
const salaries = await portalService.getMySalaries(req.user?.employeeId);
res.json(ResponseFormatter.success(salaries));
} catch (error) {
next(error);
}
}
}
export const portalController = new PortalController();

View File

@@ -0,0 +1,114 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service';
class PortalService {
private requireEmployeeId(employeeId: string | undefined): string {
if (!employeeId) {
throw new AppError(403, 'يجب ربط المستخدم بموظف للوصول للبوابة - Employee link required for portal access');
}
return employeeId;
}
async getMe(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
const employee = await prisma.employee.findUnique({
where: { id: empId },
select: {
id: true,
uniqueEmployeeId: true,
firstName: true,
lastName: true,
firstNameAr: true,
lastNameAr: true,
email: true,
department: { select: { name: true, nameAr: true } },
position: { select: { title: true, titleAr: true } },
},
});
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
hrService.getLeaveBalance(empId, new Date().getFullYear()),
]);
return {
employee,
stats: {
activeLoansCount: loansCount,
pendingLeavesCount: pendingLeaves,
pendingPurchaseRequestsCount: pendingPurchaseRequests,
leaveBalance,
},
};
}
async getMyLoans(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.loan.findMany({
where: { employeeId: empId },
include: { installmentsList: { orderBy: { installmentNumber: 'asc' } } },
orderBy: { createdAt: 'desc' },
});
}
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createLoan({ ...data, employeeId: empId }, userId);
}
async getMyLeaveBalance(employeeId: string | undefined, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const y = year || new Date().getFullYear();
return hrService.getLeaveBalance(empId, y);
}
async getMyLeaves(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.leave.findMany({
where: { employeeId: empId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
}
async getMyPurchaseRequests(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.purchaseRequest.findMany({
where: { employeeId: empId },
orderBy: { createdAt: 'desc' },
});
}
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
}
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const now = new Date();
const m = month ?? now.getMonth() + 1;
const y = year ?? now.getFullYear();
return hrService.getAttendance(empId, m, y);
}
async getMySalaries(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.salary.findMany({
where: { employeeId: empId },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
take: 24,
});
}
}
export const portalService = new PortalService();