diff --git a/backend/prisma/migrations/20260209000000_add_hr_enhancements/migration.sql b/backend/prisma/migrations/20260209000000_add_hr_enhancements/migration.sql new file mode 100644 index 0000000..7bc614c --- /dev/null +++ b/backend/prisma/migrations/20260209000000_add_hr_enhancements/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index db7075b..507bb87 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 // ============================================ diff --git a/backend/src/modules/hr/hr.controller.ts b/backend/src/modules/hr/hr.controller.ts index e2bd534..1de40fd 100644 --- a/backend/src/modules/hr/hr.controller.ts +++ b/backend/src/modules/hr/hr.controller.ts @@ -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(); diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index fc2cec3..3833917 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -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; diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index 3058ce2..dd0781a 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -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 { + 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 { + 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 = {}; + 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 { + 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 { diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts new file mode 100644 index 0000000..e16adc2 --- /dev/null +++ b/backend/src/modules/hr/portal.controller.ts @@ -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(); diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts new file mode 100644 index 0000000..981b86a --- /dev/null +++ b/backend/src/modules/hr/portal.service.ts @@ -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(); diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 0362b0c..b5da2d2 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -8,6 +8,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher' import Link from 'next/link' import { Users, + User, TrendingUp, Package, CheckSquare, @@ -85,6 +86,16 @@ function DashboardContent() { description: 'الموظفين والإجازات والرواتب', permission: 'hr' }, + { + id: 'portal', + name: 'البوابة الذاتية', + nameEn: 'My Portal', + icon: User, + color: 'bg-cyan-500', + href: '/portal', + description: 'قروضي، إجازاتي، طلبات الشراء والرواتب', + permission: 'hr' + }, { id: 'marketing', name: 'التسويق', diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx index 6bc8677..4db2520 100644 --- a/frontend/src/app/hr/page.tsx +++ b/frontend/src/app/hr/page.tsx @@ -24,10 +24,14 @@ import { User, CheckCircle2, XCircle, - Network + Network, + Banknote, + ShoppingCart, + FileText } from 'lucide-react' import dynamic from 'next/dynamic' import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees' +import { hrAdminAPI } from '@/lib/api/hrAdmin' const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false }) @@ -291,8 +295,8 @@ function HRContent() { const [positions, setPositions] = useState([]) const [loadingDepts, setLoadingDepts] = useState(false) - // Tabs: employees | departments | orgchart - const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees') + // Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts + const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees') const [hierarchy, setHierarchy] = useState([]) const [loadingHierarchy, setLoadingHierarchy] = useState(false) @@ -303,6 +307,13 @@ function HRContent() { const [deptFormErrors, setDeptFormErrors] = useState>({}) const [deptDeleteConfirm, setDeptDeleteConfirm] = useState(null) + // HR Admin tabs data + const [leavesData, setLeavesData] = useState([]) + const [loansData, setLoansData] = useState([]) + const [purchasesData, setPurchasesData] = useState([]) + const [contractsData, setContractsData] = useState([]) + const [loadingHRTab, setLoadingHRTab] = useState(false) + const fetchDepartments = useCallback(async () => { setLoadingDepts(true) try { @@ -352,6 +363,34 @@ function HRContent() { if (activeTab === 'orgchart') fetchHierarchy() }, [activeTab, fetchDepartments, fetchHierarchy]) + useEffect(() => { + if (activeTab === 'leaves' || activeTab === 'loans' || activeTab === 'purchases' || activeTab === 'contracts') { + setLoadingHRTab(true) + const load = async () => { + try { + if (activeTab === 'leaves') { + const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 }) + setLeavesData(leaves) + } else if (activeTab === 'loans') { + const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 }) + setLoansData(loans) + } else if (activeTab === 'purchases') { + const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 }) + setPurchasesData(purchaseRequests) + } else if (activeTab === 'contracts') { + const { contracts } = await hrAdminAPI.getContracts({ pageSize: 50 }) + setContractsData(contracts) + } + } catch { + toast.error('Failed to load data') + } finally { + setLoadingHRTab(false) + } + } + load() + } + }, [activeTab]) + // Fetch Employees (with debouncing for search) const fetchEmployees = useCallback(async () => { setLoading(true) @@ -720,6 +759,58 @@ function HRContent() { الهيكل التنظيمي / Org Chart + + + + @@ -1061,6 +1152,108 @@ function HRContent() { )} )} + + {activeTab === 'leaves' && ( +
+

طلبات الإجازة المعلقة / Pending Leave Requests

+ {loadingHRTab ? : leavesData.length === 0 ? ( +

No pending leaves

+ ) : ( +
+ {leavesData.map((l: any) => ( +
+
+

{l.employee?.firstName} {l.employee?.lastName}

+

{l.leaveType} - {l.days} days ({new Date(l.startDate).toLocaleDateString()} - {new Date(l.endDate).toLocaleDateString()})

+ {l.reason &&

{l.reason}

} +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === 'loans' && ( +
+

طلبات القروض المعلقة / Pending Loan Requests

+ {loadingHRTab ? : loansData.length === 0 ? ( +

No pending loans

+ ) : ( +
+ {loansData.map((l: any) => ( +
+
+

{l.employee?.firstName} {l.employee?.lastName}

+

{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)

+ {l.reason &&

{l.reason}

} +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === 'purchases' && ( +
+

طلبات الشراء المعلقة / Pending Purchase Requests

+ {loadingHRTab ? : purchasesData.length === 0 ? ( +

No pending purchase requests

+ ) : ( +
+ {purchasesData.map((pr: any) => ( +
+
+

{pr.employee?.firstName} {pr.employee?.lastName}

+

{pr.requestNumber} - {pr.totalAmount != null ? Number(pr.totalAmount).toLocaleString() + ' SAR' : '-'}

+ {pr.reason &&

{pr.reason}

} +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {activeTab === 'contracts' && ( +
+

عقود الموظفين / Employee Contracts

+ {loadingHRTab ? : contractsData.length === 0 ? ( +

No contracts

+ ) : ( +
+ + + + {contractsData.map((c: any) => ( + + + + + + + + + ))} + +
ContractEmployeeTypeSalaryPeriodStatus
{c.contractNumber}{c.employee?.firstName} {c.employee?.lastName}{c.type}{Number(c.salary).toLocaleString()} SAR{new Date(c.startDate).toLocaleDateString()}{c.endDate ? ' - ' + new Date(c.endDate).toLocaleDateString() : ''}{c.status}
+
+ )} +
+ )} {/* Create Modal */} diff --git a/frontend/src/app/portal/attendance/page.tsx b/frontend/src/app/portal/attendance/page.tsx new file mode 100644 index 0000000..11e703f --- /dev/null +++ b/frontend/src/app/portal/attendance/page.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState, useEffect } from 'react' +import { portalAPI, type Attendance } from '@/lib/api/portal' +import LoadingSpinner from '@/components/LoadingSpinner' +import { Clock } from 'lucide-react' + +export default function PortalAttendancePage() { + const [attendance, setAttendance] = useState([]) + const [loading, setLoading] = useState(true) + const [month, setMonth] = useState(new Date().getMonth() + 1) + const [year, setYear] = useState(new Date().getFullYear()) + + useEffect(() => { + setLoading(true) + portalAPI.getAttendance(month, year) + .then(setAttendance) + .catch(() => setAttendance([])) + .finally(() => setLoading(false)) + }, [month, year]) + + const months = Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleDateString('ar-SA', { month: 'long' }) })) + const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i) + + if (loading) return + + return ( +
+
+

حضوري

+
+ + +
+
+ + {attendance.length === 0 ? ( +
+ +

لا توجد سجلات حضور لهذا الشهر

+
+ ) : ( +
+ + + + + + + + + + + + {attendance.map((a) => ( + + + + + + + + ))} + +
التاريخدخولخروجساعات العملالحالة
{new Date(a.date).toLocaleDateString('ar-SA')}{a.checkIn ? new Date(a.checkIn).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}{a.checkOut ? new Date(a.checkOut).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}{a.workHours != null ? Number(a.workHours).toFixed(1) : '-'} + + {a.status === 'PRESENT' ? 'حاضر' : a.status === 'ABSENT' ? 'غائب' : a.status === 'LATE' ? 'متأخر' : a.status} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/app/portal/layout.tsx b/frontend/src/app/portal/layout.tsx new file mode 100644 index 0000000..976b12f --- /dev/null +++ b/frontend/src/app/portal/layout.tsx @@ -0,0 +1,107 @@ +'use client' + +import ProtectedRoute from '@/components/ProtectedRoute' +import { useAuth } from '@/contexts/AuthContext' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + LayoutDashboard, + Banknote, + Calendar, + ShoppingCart, + Clock, + DollarSign, + Building2, + LogOut, + User +} from 'lucide-react' + +function PortalLayoutContent({ children }: { children: React.ReactNode }) { + const { user, logout } = useAuth() + const pathname = usePathname() + + const menuItems = [ + { icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true }, + { icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' }, + { icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' }, + { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' }, + { icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' }, + { icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' }, + ] + + const isActive = (href: string, exact?: boolean) => { + if (exact) return pathname === href + return pathname.startsWith(href) + } + + return ( +
+ + +
+ {children} +
+
+ ) +} + +export default function PortalLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/app/portal/leave/page.tsx b/frontend/src/app/portal/leave/page.tsx new file mode 100644 index 0000000..97251d4 --- /dev/null +++ b/frontend/src/app/portal/leave/page.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useState, useEffect } from 'react' +import { portalAPI } from '@/lib/api/portal' +import Modal from '@/components/Modal' +import LoadingSpinner from '@/components/LoadingSpinner' +import { toast } from 'react-hot-toast' +import { Calendar, Plus } from 'lucide-react' + +const LEAVE_TYPES = [ + { value: 'ANNUAL', label: 'إجازة سنوية' }, + { value: 'SICK', label: 'إجازة مرضية' }, + { value: 'EMERGENCY', label: 'طوارئ' }, + { value: 'UNPAID', label: 'بدون راتب' }, +] + +const STATUS_MAP: Record = { + 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 PortalLeavePage() { + const [leaveBalance, setLeaveBalance] = useState([]) + const [leaves, setLeaves] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' }) + + const load = () => { + setLoading(true) + Promise.all([portalAPI.getLeaveBalance(), portalAPI.getLeaves()]) + .then(([balance, list]) => { + setLeaveBalance(balance) + setLeaves(list) + }) + .catch(() => toast.error('فشل تحميل البيانات')) + .finally(() => setLoading(false)) + } + + useEffect(() => load(), []) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!form.startDate || !form.endDate) { + toast.error('أدخل تاريخ البداية والنهاية') + return + } + if (new Date(form.endDate) < new Date(form.startDate)) { + toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية') + return + } + setSubmitting(true) + portalAPI.submitLeaveRequest({ + leaveType: form.leaveType, + startDate: form.startDate, + endDate: form.endDate, + reason: form.reason || undefined, + }) + .then(() => { + setShowModal(false) + setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' }) + toast.success('تم إرسال طلب الإجازة') + load() + }) + .catch(() => toast.error('فشل إرسال الطلب')) + .finally(() => setSubmitting(false)) + } + + if (loading) return + + return ( +
+
+

إجازاتي

+ +
+ + {leaveBalance.length > 0 && ( +
+

رصيد الإجازات

+
+ {leaveBalance.map((b) => ( +
+

+ {b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType} +

+

{b.available} يوم

+

من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})

+
+ ))} +
+
+ )} + +
+

طلباتي

+ {leaves.length === 0 ? ( +

لا توجد طلبات إجازة

+ ) : ( +
+ {leaves.map((l) => { + const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' } + return ( +
+
+

+ {l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم +

+

+ {new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')} +

+ {l.rejectedReason &&

سبب الرفض: {l.rejectedReason}

} +
+ + {statusInfo.label} + +
+ ) + })} +
+ )} +
+ + setShowModal(false)} title="طلب إجازة جديد"> +
+
+ + +
+
+
+ + setForm((p) => ({ ...p, startDate: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + required + /> +
+
+ + setForm((p) => ({ ...p, endDate: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + required + /> +
+
+
+ +