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

@@ -0,0 +1,131 @@
-- AlterTable: Add attendancePin to employees
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "attendancePin" TEXT;
-- CreateIndex (unique) on attendancePin - only if column added
CREATE UNIQUE INDEX IF NOT EXISTS "employees_attendancePin_key" ON "employees"("attendancePin");
-- AlterTable: Add ZK Tico fields to attendances
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "sourceDeviceId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "rawData" JSONB;
-- CreateIndex on sourceDeviceId
CREATE INDEX IF NOT EXISTS "attendances_sourceDeviceId_idx" ON "attendances"("sourceDeviceId");
-- CreateTable: loans
CREATE TABLE "loans" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"loanNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"installments" INTEGER NOT NULL DEFAULT 1,
"monthlyAmount" DECIMAL(12,2),
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"startDate" DATE,
"endDate" DATE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loans_pkey" PRIMARY KEY ("id")
);
-- CreateTable: loan_installments
CREATE TABLE "loan_installments" (
"id" TEXT NOT NULL,
"loanId" TEXT NOT NULL,
"installmentNumber" INTEGER NOT NULL,
"dueDate" DATE NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"paidDate" DATE,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loan_installments_pkey" PRIMARY KEY ("id")
);
-- CreateTable: purchase_requests
CREATE TABLE "purchase_requests" (
"id" TEXT NOT NULL,
"requestNumber" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"totalAmount" DECIMAL(12,2),
"reason" TEXT,
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "purchase_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable: leave_entitlements
CREATE TABLE "leave_entitlements" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"leaveType" TEXT NOT NULL,
"totalDays" INTEGER NOT NULL DEFAULT 0,
"usedDays" INTEGER NOT NULL DEFAULT 0,
"carriedOver" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "leave_entitlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable: employee_contracts
CREATE TABLE "employee_contracts" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"contractNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE,
"salary" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"documentUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employee_contracts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "loans_loanNumber_key" ON "loans"("loanNumber");
CREATE INDEX "loans_employeeId_idx" ON "loans"("employeeId");
CREATE INDEX "loans_status_idx" ON "loans"("status");
CREATE UNIQUE INDEX "loan_installments_loanId_installmentNumber_key" ON "loan_installments"("loanId", "installmentNumber");
CREATE INDEX "loan_installments_loanId_idx" ON "loan_installments"("loanId");
CREATE UNIQUE INDEX "purchase_requests_requestNumber_key" ON "purchase_requests"("requestNumber");
CREATE INDEX "purchase_requests_employeeId_idx" ON "purchase_requests"("employeeId");
CREATE INDEX "purchase_requests_status_idx" ON "purchase_requests"("status");
CREATE UNIQUE INDEX "leave_entitlements_employeeId_year_leaveType_key" ON "leave_entitlements"("employeeId", "year", "leaveType");
CREATE INDEX "leave_entitlements_employeeId_idx" ON "leave_entitlements"("employeeId");
CREATE UNIQUE INDEX "employee_contracts_contractNumber_key" ON "employee_contracts"("contractNumber");
CREATE INDEX "employee_contracts_employeeId_idx" ON "employee_contracts"("employeeId");
CREATE INDEX "employee_contracts_status_idx" ON "employee_contracts"("status");
-- AddForeignKey
ALTER TABLE "loans" ADD CONSTRAINT "loans_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_installments" ADD CONSTRAINT "loan_installments_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "loans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "purchase_requests" ADD CONSTRAINT "purchase_requests_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "leave_entitlements" ADD CONSTRAINT "leave_entitlements_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "employee_contracts" ADD CONSTRAINT "employee_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -178,6 +178,9 @@ model Employee {
// Documents
documents Json? // Array of document references
// ZK Tico / Attendance device - maps to employee pin on device
attendancePin String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -192,6 +195,10 @@ model Employee {
disciplinaryActions DisciplinaryAction[]
allowances Allowance[]
commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
@@index([departmentId])
@@index([positionId])
@@ -270,12 +277,18 @@ model Attendance {
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
notes String?
// ZK Tico / External device sync
sourceDeviceId String?
externalId String?
rawData Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, date])
@@index([employeeId])
@@index([date])
@@index([sourceDeviceId])
@@map("attendances")
}
@@ -418,6 +431,115 @@ model DisciplinaryAction {
@@map("disciplinary_actions")
}
model Loan {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
loanNumber String @unique
type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc.
amount Decimal @db.Decimal(12, 2)
currency String @default("SAR")
installments Int @default(1)
monthlyAmount Decimal? @db.Decimal(12, 2)
reason String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF
approvedBy String?
approvedAt DateTime?
rejectedReason String?
startDate DateTime? @db.Date
endDate DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
installmentsList LoanInstallment[]
@@index([employeeId])
@@index([status])
@@map("loans")
}
model LoanInstallment {
id String @id @default(uuid())
loanId String
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
installmentNumber Int
dueDate DateTime @db.Date
amount Decimal @db.Decimal(12, 2)
paidDate DateTime? @db.Date
status String @default("PENDING") // PENDING, PAID, OVERDUE
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([loanId, installmentNumber])
@@index([loanId])
@@map("loan_installments")
}
model PurchaseRequest {
id String @id @default(uuid())
requestNumber String @unique
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
items Json // Array of { description, quantity, estimatedPrice, etc. }
totalAmount Decimal? @db.Decimal(12, 2)
reason String?
priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED
approvedBy String?
approvedAt DateTime?
rejectedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("purchase_requests")
}
model LeaveEntitlement {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
year Int
leaveType String // ANNUAL, SICK, EMERGENCY, etc.
totalDays Int @default(0)
usedDays Int @default(0)
carriedOver Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, year, leaveType])
@@index([employeeId])
@@map("leave_entitlements")
}
model EmployeeContract {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
contractNumber String @unique
type String // FIXED, UNLIMITED, PROBATION, etc.
startDate DateTime @db.Date
endDate DateTime? @db.Date
salary Decimal @db.Decimal(12, 2)
currency String @default("SAR")
documentUrl String?
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("employee_contracts")
}
// ============================================
// MODULE 1: CONTACT MANAGEMENT
// ============================================

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();