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
documents Json? // Array of document references documents Json? // Array of document references
// ZK Tico / Attendance device - maps to employee pin on device
attendancePin String? @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -192,6 +195,10 @@ model Employee {
disciplinaryActions DisciplinaryAction[] disciplinaryActions DisciplinaryAction[]
allowances Allowance[] allowances Allowance[]
commissions Commission[] commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
@@index([departmentId]) @@index([departmentId])
@@index([positionId]) @@index([positionId])
@@ -270,12 +277,18 @@ model Attendance {
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc. status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
notes String? notes String?
// ZK Tico / External device sync
sourceDeviceId String?
externalId String?
rawData Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([employeeId, date]) @@unique([employeeId, date])
@@index([employeeId]) @@index([employeeId])
@@index([date]) @@index([date])
@@index([sourceDeviceId])
@@map("attendances") @@map("attendances")
} }
@@ -418,6 +431,115 @@ model DisciplinaryAction {
@@map("disciplinary_actions") @@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 // 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 ========== // ========== LEAVES ==========
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { 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 ========== // ========== SALARIES ==========
async processSalary(req: AuthRequest, res: Response, next: NextFunction) { async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
@@ -187,6 +220,198 @@ export class HRController {
next(error); 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(); export const hrController = new HRController();

View File

@@ -1,12 +1,24 @@
import { Router } from 'express'; import { Router } from 'express';
import { body, param } from 'express-validator';
import { hrController } from './hr.controller'; import { hrController } from './hr.controller';
import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth'; import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router(); const router = Router();
router.use(authenticate); 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 ========== // ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); 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.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance); router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
// ========== LEAVES ========== // ========== LEAVES ==========
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest); router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave); router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
// ========== SALARIES ========== // ========== SALARIES ==========
@@ -41,5 +56,35 @@ router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions); 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; export default router;

View File

@@ -227,12 +227,74 @@ class HRService {
async recordAttendance(data: any, userId: string) { async recordAttendance(data: any, userId: string) {
const attendance = await prisma.attendance.create({ const attendance = await prisma.attendance.create({
data, data: {
...data,
sourceDeviceId: data.sourceDeviceId ?? undefined,
externalId: data.externalId ?? undefined,
rawData: data.rawData ?? undefined,
},
}); });
return attendance; 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) { async getAttendance(employeeId: string, month: number, year: number) {
return prisma.attendance.findMany({ return prisma.attendance.findMany({
where: { where: {
@@ -251,10 +313,24 @@ class HRService {
// ========== LEAVES ========== // ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) { async createLeaveRequest(data: any, userId: string) {
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const 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({ const leave = await prisma.leave.create({
data: { data: {
...data, ...data,
days: this.calculateLeaveDays(data.startDate, data.endDate), days,
}, },
include: { include: {
employee: true, employee: true,
@@ -278,12 +354,16 @@ class HRService {
status: 'APPROVED', status: 'APPROVED',
approvedBy, approvedBy,
approvedAt: new Date(), approvedAt: new Date(),
rejectedReason: null,
}, },
include: { include: {
employee: true, employee: true,
}, },
}); });
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: leave.id, entityId: leave.id,
@@ -294,6 +374,62 @@ class HRService {
return leave; 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 ========== // ========== SALARIES ==========
async processSalary(employeeId: string, month: number, year: number, userId: string) { async processSalary(employeeId: string, month: number, year: number, userId: string) {
@@ -380,6 +516,323 @@ class HRService {
return salary; 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 ========== // ========== HELPERS ==========
private async generateEmployeeId(): Promise<string> { 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();

View File

@@ -8,6 +8,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link' import Link from 'next/link'
import { import {
Users, Users,
User,
TrendingUp, TrendingUp,
Package, Package,
CheckSquare, CheckSquare,
@@ -85,6 +86,16 @@ function DashboardContent() {
description: 'الموظفين والإجازات والرواتب', description: 'الموظفين والإجازات والرواتب',
permission: 'hr' permission: 'hr'
}, },
{
id: 'portal',
name: 'البوابة الذاتية',
nameEn: 'My Portal',
icon: User,
color: 'bg-cyan-500',
href: '/portal',
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
permission: 'hr'
},
{ {
id: 'marketing', id: 'marketing',
name: 'التسويق', name: 'التسويق',

View File

@@ -24,10 +24,14 @@ import {
User, User,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Network Network,
Banknote,
ShoppingCart,
FileText
} from 'lucide-react' } from 'lucide-react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees' 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 }) const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
@@ -291,8 +295,8 @@ function HRContent() {
const [positions, setPositions] = useState<any[]>([]) const [positions, setPositions] = useState<any[]>([])
const [loadingDepts, setLoadingDepts] = useState(false) const [loadingDepts, setLoadingDepts] = useState(false)
// Tabs: employees | departments | orgchart // Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees') const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees')
const [hierarchy, setHierarchy] = useState<Department[]>([]) const [hierarchy, setHierarchy] = useState<Department[]>([])
const [loadingHierarchy, setLoadingHierarchy] = useState(false) const [loadingHierarchy, setLoadingHierarchy] = useState(false)
@@ -303,6 +307,13 @@ function HRContent() {
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({}) const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null) const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
// HR Admin tabs data
const [leavesData, setLeavesData] = useState<any[]>([])
const [loansData, setLoansData] = useState<any[]>([])
const [purchasesData, setPurchasesData] = useState<any[]>([])
const [contractsData, setContractsData] = useState<any[]>([])
const [loadingHRTab, setLoadingHRTab] = useState(false)
const fetchDepartments = useCallback(async () => { const fetchDepartments = useCallback(async () => {
setLoadingDepts(true) setLoadingDepts(true)
try { try {
@@ -352,6 +363,34 @@ function HRContent() {
if (activeTab === 'orgchart') fetchHierarchy() if (activeTab === 'orgchart') fetchHierarchy()
}, [activeTab, fetchDepartments, 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) // Fetch Employees (with debouncing for search)
const fetchEmployees = useCallback(async () => { const fetchEmployees = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -720,6 +759,58 @@ function HRContent() {
الهيكل التنظيمي / Org Chart الهيكل التنظيمي / Org Chart
</span> </span>
</button> </button>
<button
onClick={() => setActiveTab('leaves')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'leaves'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
الإجازات / Leaves
</span>
</button>
<button
onClick={() => setActiveTab('loans')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'loans'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<Banknote className="h-4 w-4" />
القروض / Loans
</span>
</button>
<button
onClick={() => setActiveTab('purchases')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'purchases'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
طلبات الشراء / Purchases
</span>
</button>
<button
onClick={() => setActiveTab('contracts')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'contracts'
? 'border-red-600 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
العقود / Contracts
</span>
</button>
</nav> </nav>
</div> </div>
</div> </div>
@@ -1061,6 +1152,108 @@ function HRContent() {
)} )}
</div> </div>
)} )}
{activeTab === 'leaves' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات الإجازة المعلقة / Pending Leave Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : leavesData.length === 0 ? (
<p className="text-gray-500">No pending leaves</p>
) : (
<div className="space-y-3">
{leavesData.map((l: any) => (
<div key={l.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
<p className="text-sm text-gray-600">{l.leaveType} - {l.days} days ({new Date(l.startDate).toLocaleDateString()} - {new Date(l.endDate).toLocaleDateString()})</p>
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approveLeave(l.id); toast.success('Approved'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLeave(l.id, r); toast.success('Rejected'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'loans' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات القروض المعلقة / Pending Loan Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : loansData.length === 0 ? (
<p className="text-gray-500">No pending loans</p>
) : (
<div className="space-y-3">
{loansData.map((l: any) => (
<div key={l.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
<p className="text-sm text-gray-600">{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)</p>
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approveLoan(l.id); toast.success('Approved'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLoan(l.id, r); toast.success('Rejected'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'purchases' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">طلبات الشراء المعلقة / Pending Purchase Requests</h2>
{loadingHRTab ? <LoadingSpinner /> : purchasesData.length === 0 ? (
<p className="text-gray-500">No pending purchase requests</p>
) : (
<div className="space-y-3">
{purchasesData.map((pr: any) => (
<div key={pr.id} className="flex justify-between items-center py-3 border-b">
<div>
<p className="font-medium">{pr.employee?.firstName} {pr.employee?.lastName}</p>
<p className="text-sm text-gray-600">{pr.requestNumber} - {pr.totalAmount != null ? Number(pr.totalAmount).toLocaleString() + ' SAR' : '-'}</p>
{pr.reason && <p className="text-xs text-gray-500">{pr.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { await hrAdminAPI.approvePurchaseRequest(pr.id); toast.success('Approved'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectPurchaseRequest(pr.id, r); toast.success('Rejected'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'contracts' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-4">عقود الموظفين / Employee Contracts</h2>
{loadingHRTab ? <LoadingSpinner /> : contractsData.length === 0 ? (
<p className="text-gray-500">No contracts</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b"><th className="text-right py-2">Contract</th><th className="text-right py-2">Employee</th><th className="text-right py-2">Type</th><th className="text-right py-2">Salary</th><th className="text-right py-2">Period</th><th className="text-right py-2">Status</th></tr></thead>
<tbody>
{contractsData.map((c: any) => (
<tr key={c.id} className="border-b">
<td className="py-2">{c.contractNumber}</td>
<td className="py-2">{c.employee?.firstName} {c.employee?.lastName}</td>
<td className="py-2">{c.type}</td>
<td className="py-2">{Number(c.salary).toLocaleString()} SAR</td>
<td className="py-2">{new Date(c.startDate).toLocaleDateString()}{c.endDate ? ' - ' + new Date(c.endDate).toLocaleDateString() : ''}</td>
<td className="py-2"><span className={`px-2 py-0.5 rounded text-xs ${c.status === 'ACTIVE' ? 'bg-green-100' : 'bg-gray-100'}`}>{c.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</main> </main>
{/* Create Modal */} {/* Create Modal */}

View File

@@ -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<Attendance[]>([])
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 <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-2xl font-bold text-gray-900">حضوري</h1>
<div className="flex gap-2">
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{months.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
{attendance.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات حضور لهذا الشهر</p>
</div>
) : (
<div className="bg-white rounded-xl shadow overflow-hidden border border-gray-100">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-right py-3 px-4">التاريخ</th>
<th className="text-right py-3 px-4">دخول</th>
<th className="text-right py-3 px-4">خروج</th>
<th className="text-right py-3 px-4">ساعات العمل</th>
<th className="text-right py-3 px-4">الحالة</th>
</tr>
</thead>
<tbody>
{attendance.map((a) => (
<tr key={a.id} className="border-t">
<td className="py-3 px-4">{new Date(a.date).toLocaleDateString('ar-SA')}</td>
<td className="py-3 px-4">{a.checkIn ? new Date(a.checkIn).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.checkOut ? new Date(a.checkOut).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.workHours != null ? Number(a.workHours).toFixed(1) : '-'}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs ${
a.status === 'PRESENT' ? 'bg-green-100 text-green-800' :
a.status === 'ABSENT' ? 'bg-red-100 text-red-800' :
a.status === 'LATE' ? 'bg-amber-100 text-amber-800' : 'bg-gray-100'
}`}>
{a.status === 'PRESENT' ? 'حاضر' : a.status === 'ABSENT' ? 'غائب' : a.status === 'LATE' ? 'متأخر' : a.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="min-h-screen bg-gray-50 flex" dir="rtl">
<aside className="w-64 bg-white border-l shadow-lg fixed h-full overflow-y-auto">
<div className="p-6 border-b">
<div className="flex items-center gap-3 mb-4">
<div className="bg-teal-600 p-2 rounded-lg">
<User className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">البوابة الذاتية</h2>
<p className="text-xs text-gray-600">Employee Portal</p>
</div>
</div>
<div className="bg-teal-50 border border-teal-200 rounded-lg p-3">
<p className="text-xs font-semibold text-teal-900">{user?.username}</p>
<p className="text-xs text-teal-700">{user?.role?.name || 'موظف'}</p>
</div>
</div>
<nav className="p-4">
{menuItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href, item.exact)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg mb-2 transition-all ${
active ? 'bg-teal-600 text-white shadow-md' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{item.label}</span>
</Link>
)
})}
<hr className="my-4 border-gray-200" />
<Link
href="/dashboard"
className="flex items-center gap-3 px-4 py-3 rounded-lg mb-2 text-gray-700 hover:bg-gray-100 transition-all"
>
<Building2 className="h-5 w-5" />
<span className="font-medium">العودة للنظام</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 transition-all"
>
<LogOut className="h-5 w-5" />
<span className="font-medium">تسجيل الخروج</span>
</button>
</nav>
</aside>
<main className="mr-64 flex-1 p-8">
{children}
</main>
</div>
)
}
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute>
<PortalLayoutContent>{children}</PortalLayoutContent>
</ProtectedRoute>
)
}

View File

@@ -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<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمدة', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
}
export default function PortalLeavePage() {
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
const [leaves, setLeaves] = useState<any[]>([])
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 <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب إجازة
</button>
</div>
{leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{leaveBalance.map((b) => (
<div key={b.leaveType} className="border rounded-lg p-4">
<p className="text-sm text-gray-600">
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
</div>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
{leaves.length === 0 ? (
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
) : (
<div className="space-y-3">
{leaves.map((l) => {
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
return (
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
<div>
<p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم
</p>
<p className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
</p>
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
})}
</div>
)}
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
<select
value={form.leaveType}
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Loan } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
}
export default function PortalLoansPage() {
const [loans, setLoans] = useState<Loan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
useEffect(() => {
portalAPI.getLoans()
.then(setLoans)
.catch(() => toast.error('فشل تحميل القروض'))
.finally(() => setLoading(false))
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(form.amount)
if (!amount || amount <= 0) {
toast.error('أدخل مبلغاً صالحاً')
return
}
setSubmitting(true)
portalAPI.submitLoanRequest({
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason || undefined,
})
.then((loan) => {
setLoans((prev) => [loan, ...prev])
setShowModal(false)
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
toast.success('تم إرسال طلب القرض')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب قرض
</button>
</div>
{loans.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Banknote className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد قروض</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب قرض
</button>
</div>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const statusInfo = STATUS_MAP[loan.status] || { label: loan.status, color: 'bg-gray-100 text-gray-800' }
return (
<div key={loan.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{loan.loanNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{loan.type === 'SALARY_ADVANCE' ? 'سلفة راتب' : loan.type} - {Number(loan.amount).toLocaleString()} ر.س
</p>
<p className="text-xs text-gray-500 mt-1">
{loan.installments} أقساط × {loan.monthlyAmount ? Number(loan.monthlyAmount).toLocaleString() : '-'} ر.س
</p>
{loan.installmentsList && loan.installmentsList.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{loan.installmentsList.map((i) => (
<span
key={i.id}
className={`text-xs px-2 py-1 rounded ${
i.status === 'PAID' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{i.installmentNumber}: {i.status === 'PAID' ? 'مسدد' : 'معلق'}
</span>
))}
</div>
)}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{loan.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
)}
</div>
)
})}
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="SALARY_ADVANCE">سلفة راتب</option>
<option value="EQUIPMENT">معدات</option>
<option value="PERSONAL">شخصي</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">المبلغ (ر.س) *</label>
<input
type="number"
min="1"
step="0.01"
value={form.amount}
onChange={(e) => setForm((p) => ({ ...p, amount: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">عدد الأقساط</label>
<input
type="number"
min="1"
value={form.installments}
onChange={(e) => setForm((p) => ({ ...p, installments: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
const [data, setData] = useState<PortalProfile | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getMe()
.then(setData)
.catch(() => toast.error('فشل تحميل البيانات'))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
const { employee, stats } = data
const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}`
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">مرحباً، {name}</h1>
<p className="text-gray-600 mt-1">
{employee.department?.nameAr || employee.department?.name} - {employee.position?.titleAr || employee.position?.title}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">رصيد الإجازات</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{stats.leaveBalance.reduce((s, b) => s + b.available, 0)} يوم
</p>
</div>
<div className="bg-teal-100 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-teal-600" />
</div>
</div>
<Link href="/portal/leave" className="mt-4 text-sm text-teal-600 hover:underline flex items-center gap-1">
عرض التفاصيل <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">القروض النشطة</p>
<p className="text-2xl font-bold text-amber-600 mt-1">{stats.activeLoansCount}</p>
</div>
<div className="bg-amber-100 p-3 rounded-lg">
<Banknote className="h-6 w-6 text-amber-600" />
</div>
</div>
<Link href="/portal/loans" className="mt-4 text-sm text-amber-600 hover:underline flex items-center gap-1">
عرض القروض <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
<p className="text-2xl font-bold text-blue-600 mt-1">
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
<ShoppingCart className="h-6 w-6 text-blue-600" />
</div>
</div>
<div className="mt-4 flex gap-4">
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
</div>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">حضوري</p>
<p className="text-sm text-gray-500 mt-1">هذا الشهر</p>
</div>
<div className="bg-gray-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-gray-600" />
</div>
</div>
<Link href="/portal/attendance" className="mt-4 text-sm text-gray-600 hover:underline flex items-center gap-1">
عرض الحضور <ArrowLeft className="h-4 w-4" />
</Link>
</div>
</div>
{stats.leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">تفاصيل رصيد الإجازات</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-right py-2">نوع الإجازة</th>
<th className="text-right py-2">الإجمالي</th>
<th className="text-right py-2">المستخدم</th>
<th className="text-right py-2">المتبقي</th>
</tr>
</thead>
<tbody>
{stats.leaveBalance.map((b) => (
<tr key={b.leaveType} className="border-b last:border-0">
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
<td className="py-2">{b.totalDays + b.carriedOver}</td>
<td className="py-2">{b.usedDays}</td>
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex flex-wrap gap-4">
<Link
href="/portal/loans"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700"
>
<Plus className="h-4 w-4" />
طلب قرض
</Link>
<Link
href="/portal/leave"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب إجازة
</Link>
<Link
href="/portal/purchase-requests"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
طلب شراء
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type PurchaseRequest } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { ShoppingCart, Plus } from 'lucide-react'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
ORDERED: { label: 'تم الطلب', color: 'bg-blue-100 text-blue-800' },
}
export default function PortalPurchaseRequestsPage() {
const [requests, setRequests] = useState<PurchaseRequest[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
reason: '',
priority: 'NORMAL',
})
useEffect(() => {
portalAPI.getPurchaseRequests()
.then(setRequests)
.catch(() => toast.error('فشل تحميل الطلبات'))
.finally(() => setLoading(false))
}, [])
const addItem = () => setForm((p) => ({ ...p, items: [...p.items, { description: '', quantity: 1, estimatedPrice: '' }] }))
const removeItem = (i: number) =>
setForm((p) => ({ ...p, items: p.items.filter((_, idx) => idx !== i) }))
const updateItem = (i: number, key: string, value: string | number) =>
setForm((p) => ({
...p,
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
}))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const items = form.items
.filter((it) => it.description.trim())
.map((it) => ({
description: it.description,
quantity: it.quantity || 1,
estimatedPrice: parseFloat(String(it.estimatedPrice)) || 0,
}))
if (items.length === 0) {
toast.error('أضف صنفاً واحداً على الأقل')
return
}
setSubmitting(true)
portalAPI.submitPurchaseRequest({
items,
reason: form.reason || undefined,
priority: form.priority,
})
.then((pr) => {
setRequests((prev) => [pr, ...prev])
setShowModal(false)
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
toast.success('تم إرسال طلب الشراء')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب شراء جديد
</button>
</div>
{requests.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد طلبات شراء</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب شراء
</button>
</div>
) : (
<div className="space-y-4">
{requests.map((pr) => {
const statusInfo = STATUS_MAP[pr.status] || { label: pr.status, color: 'bg-gray-100 text-gray-800' }
const items = Array.isArray(pr.items) ? pr.items : []
return (
<div key={pr.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{pr.requestNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{pr.totalAmount != null ? `${Number(pr.totalAmount).toLocaleString()} ر.س` : '-'}
</p>
{items.length > 0 && (
<ul className="mt-2 text-sm text-gray-600 list-disc list-inside">
{items.slice(0, 3).map((it: any, i: number) => (
<li key={i}>
{it.description} × {it.quantity || 1}
{it.estimatedPrice ? ` (${Number(it.estimatedPrice).toLocaleString()} ر.س)` : ''}
</li>
))}
{items.length > 3 && <li>... و {items.length - 3} أصناف أخرى</li>}
</ul>
)}
{pr.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
</div>
)
})}
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">الأصناف</label>
<button type="button" onClick={addItem} className="text-teal-600 text-sm hover:underline">
+ إضافة صنف
</button>
</div>
<div className="space-y-3">
{form.items.map((it, i) => (
<div key={i} className="flex gap-2 items-start border p-2 rounded">
<input
placeholder="الوصف"
value={it.description}
onChange={(e) => updateItem(i, 'description', e.target.value)}
className="flex-1 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="1"
placeholder="الكمية"
value={it.quantity}
onChange={(e) => updateItem(i, 'quantity', parseInt(e.target.value) || 1)}
className="w-20 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="السعر"
value={it.estimatedPrice}
onChange={(e) => updateItem(i, 'estimatedPrice', e.target.value)}
className="w-24 px-2 py-1 border rounded text-sm"
/>
<button type="button" onClick={() => removeItem(i)} className="text-red-600 text-sm">
حذف
</button>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">الأولوية</label>
<select
value={form.priority}
onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="LOW">منخفضة</option>
<option value="NORMAL">عادية</option>
<option value="HIGH">عالية</option>
<option value="URGENT">عاجلة</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب / التوضيح</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={2}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Salary } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { DollarSign } from 'lucide-react'
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
export default function PortalSalariesPage() {
const [salaries, setSalaries] = useState<Salary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getSalaries()
.then(setSalaries)
.catch(() => setSalaries([]))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">رواتبي</h1>
{salaries.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات رواتب</p>
</div>
) : (
<div className="space-y-4">
{salaries.map((s) => (
<div key={s.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">
{MONTHS_AR[s.month - 1]} {s.year}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{Number(s.netSalary).toLocaleString()} ر.س
</p>
<div className="mt-2 text-sm text-gray-600 space-y-1">
<p>الأساس: {Number(s.basicSalary).toLocaleString()} | البدلات: {Number(s.allowances).toLocaleString()} | الخصومات: {Number(s.deductions).toLocaleString()}</p>
<p>عمولة: {Number(s.commissions).toLocaleString()} | إضافي: {Number(s.overtimePay).toLocaleString()}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
s.status === 'PAID' ? 'bg-green-100 text-green-800' :
s.status === 'APPROVED' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
}`}>
{s.status === 'PAID' ? 'مدفوع' : s.status === 'APPROVED' ? 'معتمد' : 'قيد المعالجة'}
</span>
</div>
{s.paidDate && (
<p className="text-xs text-gray-500 mt-2">تاريخ الدفع: {new Date(s.paidDate).toLocaleDateString('ar-SA')}</p>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { api } from '../api'
export const hrAdminAPI = {
// Leaves
getLeaves: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/leaves?${q}`)
return { leaves: res.data.data || [], pagination: res.data.pagination }
},
approveLeave: (id: string) => api.post(`/hr/leaves/${id}/approve`),
rejectLeave: (id: string, rejectedReason: string) => api.post(`/hr/leaves/${id}/reject`, { rejectedReason }),
// Loans
getLoans: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/loans?${q}`)
return { loans: res.data.data || [], pagination: res.data.pagination }
},
getLoanById: (id: string) => api.get(`/hr/loans/${id}`),
createLoan: (data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }) =>
api.post('/hr/loans', data),
approveLoan: (id: string, startDate?: string) => api.post(`/hr/loans/${id}/approve`, { startDate: startDate || new Date().toISOString().split('T')[0] }),
rejectLoan: (id: string, rejectedReason: string) => api.post(`/hr/loans/${id}/reject`, { rejectedReason }),
payInstallment: (loanId: string, installmentId: string, paidDate?: string) =>
api.post(`/hr/loans/${loanId}/pay-installment`, { installmentId, paidDate: paidDate || new Date().toISOString().split('T')[0] }),
// Purchase Requests
getPurchaseRequests: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/purchase-requests?${q}`)
return { purchaseRequests: res.data.data || [], pagination: res.data.pagination }
},
approvePurchaseRequest: (id: string) => api.post(`/hr/purchase-requests/${id}/approve`),
rejectPurchaseRequest: (id: string, rejectedReason: string) => api.post(`/hr/purchase-requests/${id}/reject`, { rejectedReason }),
// Leave Entitlements
getLeaveBalance: (employeeId: string, year?: number) => {
const q = year ? `?year=${year}` : ''
return api.get(`/hr/leave-balance/${employeeId}${q}`).then((r) => r.data.data)
},
getLeaveEntitlements: (params?: { employeeId?: string; year?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.year) q.append('year', String(params.year))
return api.get(`/hr/leave-entitlements?${q}`).then((r) => r.data.data)
},
upsertLeaveEntitlement: (data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }) =>
api.post('/hr/leave-entitlements', data),
// Employee Contracts
getContracts: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/contracts?${q}`)
return { contracts: res.data.data || [], pagination: res.data.pagination }
},
createContract: (data: { employeeId: string; type: string; startDate: string; endDate?: string; salary: number; documentUrl?: string; notes?: string }) =>
api.post('/hr/contracts', data),
updateContract: (id: string, data: Partial<{ type: string; endDate: string; salary: number; status: string; notes: string }>) =>
api.put(`/hr/contracts/${id}`, data),
}

View File

@@ -0,0 +1,163 @@
import { api } from '../api'
export interface PortalProfile {
employee: {
id: string
uniqueEmployeeId: string
firstName: string
lastName: string
firstNameAr?: string | null
lastNameAr?: string | null
email: string
department?: { name: string; nameAr?: string | null }
position?: { title: string; titleAr?: string | null }
}
stats: {
activeLoansCount: number
pendingLeavesCount: number
pendingPurchaseRequestsCount: number
leaveBalance: Array<{
leaveType: string
totalDays: number
carriedOver: number
usedDays: number
available: number
}>
}
}
export interface Loan {
id: string
loanNumber: string
type: string
amount: number
currency: string
installments: number
monthlyAmount?: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
startDate?: string | null
endDate?: string | null
createdAt: string
installmentsList?: Array<{
id: string
installmentNumber: number
dueDate: string
amount: number
paidDate?: string | null
status: string
}>
}
export interface Leave {
id: string
leaveType: string
startDate: string
endDate: string
days: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface PurchaseRequest {
id: string
requestNumber: string
items: any[]
totalAmount?: number | null
reason?: string | null
priority: string
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface Attendance {
id: string
date: string
checkIn?: string | null
checkOut?: string | null
workHours?: number | null
overtimeHours?: number | null
status: string
}
export interface Salary {
id: string
month: number
year: number
basicSalary: number
allowances: number
deductions: number
commissions: number
overtimePay: number
netSalary: number
status: string
paidDate?: string | null
createdAt: string
}
export const portalAPI = {
getMe: async (): Promise<PortalProfile> => {
const response = await api.get('/hr/portal/me')
return response.data.data
},
getLoans: async (): Promise<Loan[]> => {
const response = await api.get('/hr/portal/loans')
return response.data.data || []
},
submitLoanRequest: async (data: { type: string; amount: number; installments?: number; reason?: string }): Promise<Loan> => {
const response = await api.post('/hr/portal/loans', data)
return response.data.data
},
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
const params = year ? `?year=${year}` : ''
const response = await api.get(`/hr/portal/leave-balance${params}`)
return response.data.data || []
},
getLeaves: async (): Promise<Leave[]> => {
const response = await api.get('/hr/portal/leaves')
return response.data.data || []
},
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => {
const response = await api.post('/hr/portal/leaves', data)
return response.data.data
},
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
const response = await api.get('/hr/portal/purchase-requests')
return response.data.data || []
},
submitPurchaseRequest: async (data: { items: Array<{ description: string; quantity?: number; estimatedPrice?: number }>; reason?: string; priority?: string }): Promise<PurchaseRequest> => {
const response = await api.post('/hr/portal/purchase-requests', data)
return response.data.data
},
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
const params = new URLSearchParams()
if (month) params.append('month', String(month))
if (year) params.append('year', String(year))
const query = params.toString() ? `?${params.toString()}` : ''
const response = await api.get(`/hr/portal/attendance${query}`)
return response.data.data || []
},
getSalaries: async (): Promise<Salary[]> => {
const response = await api.get('/hr/portal/salaries')
return response.data.data || []
},
}