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:
@@ -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;
|
||||||
@@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
106
backend/src/modules/hr/portal.controller.ts
Normal file
106
backend/src/modules/hr/portal.controller.ts
Normal 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();
|
||||||
114
backend/src/modules/hr/portal.service.ts
Normal file
114
backend/src/modules/hr/portal.service.ts
Normal 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();
|
||||||
@@ -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: 'التسويق',
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
94
frontend/src/app/portal/attendance/page.tsx
Normal file
94
frontend/src/app/portal/attendance/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/app/portal/layout.tsx
Normal file
107
frontend/src/app/portal/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
frontend/src/app/portal/leave/page.tsx
Normal file
189
frontend/src/app/portal/leave/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
frontend/src/app/portal/loans/page.tsx
Normal file
179
frontend/src/app/portal/loans/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
frontend/src/app/portal/page.tsx
Normal file
158
frontend/src/app/portal/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal file
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/app/portal/salaries/page.tsx
Normal file
65
frontend/src/app/portal/salaries/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/src/lib/api/hrAdmin.ts
Normal file
76
frontend/src/lib/api/hrAdmin.ts
Normal 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),
|
||||||
|
}
|
||||||
163
frontend/src/lib/api/portal.ts
Normal file
163
frontend/src/lib/api/portal.ts
Normal 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 || []
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user