addition expense claims
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE "expense_claims" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"employeeId" TEXT NOT NULL,
|
||||||
|
"claimNumber" TEXT NOT NULL,
|
||||||
|
"expenseDate" DATE,
|
||||||
|
"amount" DECIMAL(12,2),
|
||||||
|
"description" TEXT,
|
||||||
|
"projectOrTender" TEXT,
|
||||||
|
"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 "expense_claims_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "expense_claims_claimNumber_key" ON "expense_claims"("claimNumber");
|
||||||
|
CREATE INDEX "expense_claims_employeeId_idx" ON "expense_claims"("employeeId");
|
||||||
|
CREATE INDEX "expense_claims_status_idx" ON "expense_claims"("status");
|
||||||
|
|
||||||
|
ALTER TABLE "expense_claims"
|
||||||
|
ADD CONSTRAINT "expense_claims_employeeId_fkey"
|
||||||
|
FOREIGN KEY ("employeeId") REFERENCES "employees"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "expense_claims"
|
||||||
|
ADD COLUMN "items" JSONB;
|
||||||
|
|
||||||
|
ALTER TABLE "expense_claims"
|
||||||
|
ADD COLUMN "totalAmount" DECIMAL(12,2);
|
||||||
|
|
||||||
|
UPDATE "expense_claims"
|
||||||
|
SET "totalAmount" = "amount"
|
||||||
|
WHERE "totalAmount" IS NULL AND "amount" IS NOT NULL;
|
||||||
@@ -200,6 +200,7 @@ model Employee {
|
|||||||
commissions Commission[]
|
commissions Commission[]
|
||||||
loans Loan[]
|
loans Loan[]
|
||||||
purchaseRequests PurchaseRequest[]
|
purchaseRequests PurchaseRequest[]
|
||||||
|
expenseClaims ExpenseClaim[]
|
||||||
leaveEntitlements LeaveEntitlement[]
|
leaveEntitlements LeaveEntitlement[]
|
||||||
employeeContracts EmployeeContract[]
|
employeeContracts EmployeeContract[]
|
||||||
tenderDirectivesAssigned TenderDirective[]
|
tenderDirectivesAssigned TenderDirective[]
|
||||||
@@ -503,6 +504,34 @@ model PurchaseRequest {
|
|||||||
@@map("purchase_requests")
|
@@map("purchase_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ExpenseClaim {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
|
||||||
|
claimNumber String @unique
|
||||||
|
|
||||||
|
items Json?
|
||||||
|
totalAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
|
||||||
|
expenseDate DateTime? @db.Date
|
||||||
|
amount Decimal? @db.Decimal(12, 2)
|
||||||
|
description String?
|
||||||
|
projectOrTender String?
|
||||||
|
|
||||||
|
status String @default("PENDING")
|
||||||
|
approvedBy String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
rejectedReason String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([employeeId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("expense_claims")
|
||||||
|
}
|
||||||
|
|
||||||
model LeaveEntitlement {
|
model LeaveEntitlement {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
employeeId String
|
employeeId String
|
||||||
|
|||||||
@@ -59,6 +59,27 @@ router.post('/portal/purchase-requests', portalController.submitPurchaseRequest)
|
|||||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||||
router.get('/portal/salaries', portalController.getMySalaries);
|
router.get('/portal/salaries', portalController.getMySalaries);
|
||||||
|
|
||||||
|
router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
|
||||||
|
router.post('/portal/expense-claims', portalController.submitExpenseClaim);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/portal/managed-expense-claims',
|
||||||
|
authorize('department_expense_claims', '*', 'read'),
|
||||||
|
portalController.getManagedExpenseClaims
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-expense-claims/:id/approve',
|
||||||
|
authorize('department_expense_claims', '*', 'approve'),
|
||||||
|
portalController.approveManagedExpenseClaim
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-expense-claims/:id/reject',
|
||||||
|
authorize('department_expense_claims', '*', 'approve'),
|
||||||
|
portalController.rejectManagedExpenseClaim
|
||||||
|
);
|
||||||
|
|
||||||
// ========== EMPLOYEES ==========
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||||
|
|||||||
@@ -176,6 +176,58 @@ export class PortalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.getMyExpenseClaims(req.user?.employeeId);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.submitExpenseClaim(req.user?.employeeId, req.body, req.user!.id);
|
||||||
|
res.status(201).json(ResponseFormatter.success(data, 'تم إرسال كشف المصاريف - Expense claim submitted'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.approveManagedExpenseClaim(req.user?.employeeId, req.params.id, req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const data = await portalService.rejectManagedExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
rejectedReason || '',
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(data, 'تم رفض كشف المصاريف - Expense claim rejected'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ class PortalService {
|
|||||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
const [loansCount, pendingLeaves, pendingPurchaseRequests, pendingExpenseClaims, leaveBalance] = await Promise.all([
|
||||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||||
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
|
prisma.expenseClaim.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ class PortalService {
|
|||||||
activeLoansCount: loansCount,
|
activeLoansCount: loansCount,
|
||||||
pendingLeavesCount: pendingLeaves,
|
pendingLeavesCount: pendingLeaves,
|
||||||
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
||||||
|
pendingExpenseClaimsCount: pendingExpenseClaims,
|
||||||
leaveBalance,
|
leaveBalance,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -492,6 +494,296 @@ class PortalService {
|
|||||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async generateExpenseClaimNumber(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `EC-${year}-`;
|
||||||
|
|
||||||
|
const last = await prisma.expenseClaim.findFirst({
|
||||||
|
where: {
|
||||||
|
claimNumber: {
|
||||||
|
startsWith: prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
claimNumber: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let next = 1;
|
||||||
|
if (last?.claimNumber) {
|
||||||
|
const parts = last.claimNumber.split('-');
|
||||||
|
next = parseInt(parts[2] || '0', 10) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyExpenseClaims(employeeId: string | undefined) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
return prisma.expenseClaim.findMany({
|
||||||
|
where: { employeeId: empId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitExpenseClaim(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: {
|
||||||
|
items?: Array<{
|
||||||
|
expenseDate?: string;
|
||||||
|
amount?: number | string;
|
||||||
|
entityName?: string;
|
||||||
|
description?: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
|
||||||
|
const normalizedItems = items
|
||||||
|
.map((item) => ({
|
||||||
|
expenseDate: item.expenseDate || '',
|
||||||
|
amount: Number(item.amount || 0),
|
||||||
|
entityName: item.entityName?.trim() || '',
|
||||||
|
description: item.description?.trim() || '',
|
||||||
|
projectOrTender: item.projectOrTender?.trim() || '',
|
||||||
|
proofRef: item.proofRef?.trim() || '',
|
||||||
|
}))
|
||||||
|
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
|
||||||
|
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimNumber = await this.generateExpenseClaimNumber();
|
||||||
|
|
||||||
|
const totalAmount = normalizedItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
|
||||||
|
const firstItem = normalizedItems[0];
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.create({
|
||||||
|
data: {
|
||||||
|
claimNumber,
|
||||||
|
employeeId: empId,
|
||||||
|
items: normalizedItems as any,
|
||||||
|
totalAmount,
|
||||||
|
expenseDate: new Date(firstItem.expenseDate),
|
||||||
|
amount: totalAmount,
|
||||||
|
description: data.description?.trim() || null,
|
||||||
|
projectOrTender: firstItem.projectOrTender || null,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
|
||||||
|
|
||||||
|
await notificationsService.notifyUsersWithPermission({
|
||||||
|
module: 'department_expense_claims',
|
||||||
|
resource: '*',
|
||||||
|
action: 'approve',
|
||||||
|
type: 'EXPENSE_CLAIM_SUBMITTED',
|
||||||
|
title: 'كشف مصاريف جديد بانتظار الموافقة',
|
||||||
|
message: `قام الموظف ${employeeFullName} بإرسال كشف مصاريف جديد برقم ${claim.claimNumber}.`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_CREATED',
|
||||||
|
title: 'تم إرسال كشف المصاريف',
|
||||||
|
message: `تم إرسال كشف المصاريف الخاص بك برقم ${claim.claimNumber} وهو الآن بانتظار المراجعة.`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
} else {
|
||||||
|
where.status = 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.expenseClaim.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedExpenseClaim(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
approvedBy: userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_APPROVED',
|
||||||
|
title: 'تمت الموافقة على كشف المصاريف',
|
||||||
|
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedExpenseClaim(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
rejectedReason: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب الرفض مطلوب');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason: rejectedReason.trim(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_REJECTED',
|
||||||
|
title: 'تم رفض كشف المصاريف',
|
||||||
|
message: `تم رفض كشف المصاريف الخاص بك برقم ${claim.claimNumber}. السبب: ${rejectedReason.trim()}`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ class NotificationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resolveApprovalRecipients(params: {
|
async resolveApprovalRecipients(params: {
|
||||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
|
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
|
||||||
fallbackEmployeeId?: string;
|
fallbackEmployeeId?: string;
|
||||||
fallbackToManager?: boolean;
|
fallbackToManager?: boolean;
|
||||||
excludeUserIds?: string[];
|
excludeUserIds?: string[];
|
||||||
@@ -260,7 +260,7 @@ class NotificationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async notifyApprovalRecipients(params: {
|
async notifyApprovalRecipients(params: {
|
||||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
|
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
|
||||||
fallbackEmployeeId?: string;
|
fallbackEmployeeId?: string;
|
||||||
fallbackToManager?: boolean;
|
fallbackToManager?: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
|||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
{ id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ const MODULES = [
|
|||||||
|
|
||||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
||||||
|
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
|
||||||
|
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const MODULES = [
|
|||||||
|
|
||||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
||||||
|
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
|
||||||
|
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
|
|||||||
@@ -95,23 +95,23 @@ function DashboardContent() {
|
|||||||
const resolveNotificationUrl = (notification: any) => {
|
const resolveNotificationUrl = (notification: any) => {
|
||||||
if (notification.entityType === 'LEAVE') {
|
if (notification.entityType === 'LEAVE') {
|
||||||
if (notification.type === 'LEAVE_REQUEST_SUBMITTED') {
|
if (notification.type === 'LEAVE_REQUEST_SUBMITTED') {
|
||||||
return '/portal/managed-leaves'
|
return '/portal/managed-leaves';
|
||||||
}
|
}
|
||||||
return '/portal/leave'
|
return '/portal/leave';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.entityType === 'OVERTIME_REQUEST') {
|
if (notification.entityType === 'OVERTIME_REQUEST') {
|
||||||
if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') {
|
if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') {
|
||||||
return '/portal/managed-overtime-requests'
|
return '/portal/managed-overtime-requests';
|
||||||
}
|
}
|
||||||
return '/portal/overtime'
|
return '/portal/overtime';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.entityType === 'PURCHASE_REQUEST') {
|
if (notification.entityType === 'PURCHASE_REQUEST') {
|
||||||
if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') {
|
if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') {
|
||||||
return '/hr?tab=purchases'
|
return '/hr?tab=purchases';
|
||||||
}
|
}
|
||||||
return '/portal/purchase-requests'
|
return '/portal/purchase-requests';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.entityType === 'LOAN') {
|
if (notification.entityType === 'LOAN') {
|
||||||
@@ -119,30 +119,67 @@ function DashboardContent() {
|
|||||||
notification.type === 'LOAN_REQUEST_SUBMITTED' ||
|
notification.type === 'LOAN_REQUEST_SUBMITTED' ||
|
||||||
notification.type === 'LOAN_REQUEST_PENDING_ADMIN'
|
notification.type === 'LOAN_REQUEST_PENDING_ADMIN'
|
||||||
) {
|
) {
|
||||||
return '/hr?tab=loans'
|
return '/hr?tab=loans';
|
||||||
}
|
}
|
||||||
return '/portal/loans'
|
return '/portal/loans';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'EXPENSE_CLAIM') {
|
||||||
|
if (notification.entityId) {
|
||||||
|
// إشعار المدير: بانتظار الموافقة
|
||||||
|
if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') {
|
||||||
|
return `/portal/managed-expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// إشعار الموظف: تم الإرسال / تمت الموافقة / تم الرفض
|
||||||
|
if (
|
||||||
|
notification.type === 'EXPENSE_CLAIM_CREATED' ||
|
||||||
|
notification.type === 'EXPENSE_CLAIM_APPROVED' ||
|
||||||
|
notification.type === 'EXPENSE_CLAIM_REJECTED'
|
||||||
|
) {
|
||||||
|
return `/portal/expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return `/portal/expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/portal/expense-claims';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') {
|
if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') {
|
||||||
if (notification.entityType === 'TENDER' && notification.entityId) {
|
if (notification.entityType === 'TENDER' && notification.entityId) {
|
||||||
return `/tenders/${notification.entityId}?tab=directives`
|
return `/tenders/${notification.entityId}?tab=directives`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '/tenders'
|
return '/tenders';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '/dashboard'
|
return '/dashboard';
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleNotificationClick = async (notification: any) => {
|
const handleNotificationClick = async (notification: any) => {
|
||||||
|
try {
|
||||||
if (!notification.isRead) {
|
if (!notification.isRead) {
|
||||||
await markNotificationAsRead(notification.id)
|
await markNotificationAsRead(notification.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = resolveNotificationUrl(notification)
|
const targetUrl = resolveNotificationUrl(notification)
|
||||||
|
|
||||||
|
console.log('🔔 Notification click →', notification)
|
||||||
|
console.log('➡️ Redirecting to:', targetUrl)
|
||||||
|
|
||||||
setShowNotifications(false)
|
setShowNotifications(false)
|
||||||
|
|
||||||
router.push(targetUrl)
|
router.push(targetUrl)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = targetUrl
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Notification click error:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleNotifications = async () => {
|
const handleToggleNotifications = async () => {
|
||||||
|
|||||||
@@ -761,7 +761,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('departments')}
|
onClick={() => openTab('departments')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'departments'
|
activeTab === 'departments'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -774,7 +774,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('orgchart')}
|
onClick={() => openTab('orgchart')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'orgchart'
|
activeTab === 'orgchart'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -787,7 +787,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('leaves')}
|
onClick={() => openTab('leaves')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'leaves'
|
activeTab === 'leaves'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -800,7 +800,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('loans')}
|
onClick={() => openTab('loans')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'loans'
|
activeTab === 'loans'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -813,7 +813,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('purchases')}
|
onClick={() => openTab('purchases')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'purchases'
|
activeTab === 'purchases'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -826,7 +826,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('contracts')}
|
onClick={() => openTab('contracts')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'contracts'
|
activeTab === 'contracts'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
|
|||||||
539
frontend/src/app/portal/expense-claims/page.tsx
Normal file
539
frontend/src/app/portal/expense-claims/page.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
type ExpenseClaimLine = {
|
||||||
|
expenseDate: string;
|
||||||
|
amount: string;
|
||||||
|
entityName: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender: string;
|
||||||
|
proofRef: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpenseClaimFormState = {
|
||||||
|
items: ExpenseClaimLine[];
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyLine = (): ExpenseClaimLine => ({
|
||||||
|
expenseDate: '',
|
||||||
|
amount: '',
|
||||||
|
entityName: '',
|
||||||
|
description: '',
|
||||||
|
projectOrTender: '',
|
||||||
|
proofRef: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialForm: ExpenseClaimFormState = {
|
||||||
|
items: [emptyLine()],
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'قيد المراجعة';
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'مقبول';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'مرفوض';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClasses(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
|
return d.toLocaleDateString('en-CA');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortalExpenseClaimsPage() {
|
||||||
|
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
||||||
|
|
||||||
|
const pendingCount = useMemo(
|
||||||
|
() => claims.filter((c) => c.status === 'PENDING').length,
|
||||||
|
[claims]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalAmount = useMemo(() => {
|
||||||
|
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
}, [form.items]);
|
||||||
|
|
||||||
|
async function loadClaims() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await portalAPI.getExpenseClaims();
|
||||||
|
setClaims(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.message || 'تعذر تحميل كشوف المصاريف');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadClaims();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, emptyLine()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (
|
||||||
|
index: number,
|
||||||
|
key: keyof ExpenseClaimLine,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item, i) =>
|
||||||
|
i === index ? { ...item, [key]: value } : item
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const items = form.items
|
||||||
|
.map((item) => ({
|
||||||
|
expenseDate: item.expenseDate,
|
||||||
|
amount: Number(item.amount || 0),
|
||||||
|
entityName: item.entityName.trim() || undefined,
|
||||||
|
description: item.description.trim(),
|
||||||
|
projectOrTender: item.projectOrTender.trim() || undefined,
|
||||||
|
proofRef: item.proofRef.trim() || undefined,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.expenseDate &&
|
||||||
|
item.description &&
|
||||||
|
Number(item.amount) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
alert('يرجى إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
await portalAPI.submitExpenseClaim({
|
||||||
|
items,
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setForm(initialForm);
|
||||||
|
setShowModal(false);
|
||||||
|
await loadClaims();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.message || 'تعذر إرسال طلب كشف المصاريف');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const claimId = searchParams.get('claimId');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" dir="rtl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">كشف المصاريف</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
يمكنك تقديم كشف مصاريف جديد ومتابعة حالة الطلبات السابقة
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
+ طلب كشف مصاريف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-gray-900">
|
||||||
|
{claims.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-sm text-gray-500">قيد المراجعة</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-yellow-600">
|
||||||
|
{pendingCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-sm text-gray-500">آخر تحديث</div>
|
||||||
|
<div className="mt-2 text-base font-semibold text-gray-900">
|
||||||
|
{claims[0]?.updatedAt ? formatDate(claims[0].updatedAt) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-white shadow-sm">
|
||||||
|
<div className="border-b px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">طلباتي</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-6 text-sm text-red-600">{error}</div>
|
||||||
|
) : claims.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500">
|
||||||
|
لا توجد طلبات كشف مصاريف حالياً
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{claims.map((claim) => {
|
||||||
|
const isSelected = claim.id === claimId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={claim.id}
|
||||||
|
className={`p-5 ${
|
||||||
|
isSelected ? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-base font-bold text-gray-900">
|
||||||
|
{claim.claimNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
|
||||||
|
claim.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getStatusLabel(claim.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
إجمالي المبلغ:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.totalAmount ?? claim.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
تاريخ الإنشاء:
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(claim.createdAt)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
عدد البنود:
|
||||||
|
</span>{' '}
|
||||||
|
{Array.isArray(claim.items)
|
||||||
|
? claim.items.length
|
||||||
|
: claim.description
|
||||||
|
? 1
|
||||||
|
: 0}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
آخر تحديث:
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(claim.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claim.description ? (
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
ملاحظات عامة:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Array.isArray(claim.items) && claim.items.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="text-sm font-medium text-gray-800">
|
||||||
|
البنود:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{claim.items.map((item: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">التاريخ:</span>{' '}
|
||||||
|
{formatDate(item.expenseDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">المبلغ:</span>{' '}
|
||||||
|
{item.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">اسم الجهة:</span>{' '}
|
||||||
|
{item.entityName || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
المشروع / المناقصة:
|
||||||
|
</span>{' '}
|
||||||
|
{item.projectOrTender || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
الأوراق المثبتة:
|
||||||
|
</span>{' '}
|
||||||
|
{item.proofRef || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="font-medium">البيان:</span>{' '}
|
||||||
|
{item.description || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">تاريخ المصروف:</span>{' '}
|
||||||
|
{formatDate(claim.expenseDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">المبلغ:</span>{' '}
|
||||||
|
{claim.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
المشروع / المناقصة:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.projectOrTender || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">البيان:</span>{' '}
|
||||||
|
{claim.description || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{claim.status === 'REJECTED' && claim.rejectedReason ? (
|
||||||
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
<span className="font-medium">سبب الرفض:</span>{' '}
|
||||||
|
{claim.rejectedReason}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title="كشف مصاريف جديد"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<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((item, index) => (
|
||||||
|
<div key={index} className="border rounded-lg p-3 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
تاريخ المصروف
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={item.expenseDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, 'expenseDate', e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
المبلغ
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="المبلغ"
|
||||||
|
value={item.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, 'amount', e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
اسم الجهة
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="اسم الجهة"
|
||||||
|
value={item.entityName}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, 'entityName', e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
المشروع / المناقصة
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="المشروع / المناقصة"
|
||||||
|
value={item.projectOrTender}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, 'projectOrTender', e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
البيان
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="البيان"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, 'description', e.target.value)
|
||||||
|
}
|
||||||
|
className="min-h-[90px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.items.length > 1 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
حذف البند
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
ملاحظات عامة
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, description: e.target.value }))
|
||||||
|
}
|
||||||
|
className="min-h-[100px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="أي ملاحظات عامة على الكشف"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700">
|
||||||
|
الإجمالي: {totalAmount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? 'جارٍ الإرسال...' : 'إرسال الطلب'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
|
FileText,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
TimerReset,
|
TimerReset,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -26,6 +27,7 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
||||||
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
||||||
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
||||||
|
{ icon: FileText, label: 'كشوف المصاريف', labelEn: 'Expense Claims', href: '/portal/expense-claims' },
|
||||||
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
|
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
|
||||||
...(hasPermission('department_overtime_requests', 'view')
|
...(hasPermission('department_overtime_requests', 'view')
|
||||||
? [{
|
? [{
|
||||||
@@ -34,6 +36,14 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
labelEn: 'Department Overtime Requests',
|
labelEn: 'Department Overtime Requests',
|
||||||
href: '/portal/managed-overtime-requests'
|
href: '/portal/managed-overtime-requests'
|
||||||
}]
|
}]
|
||||||
|
: []),
|
||||||
|
...(hasPermission('department_expense_claims', 'view')
|
||||||
|
? [{
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'طلبات كشوف المصاريف',
|
||||||
|
labelEn: 'Department Expense Claims',
|
||||||
|
href: '/portal/managed-expense-claims'
|
||||||
|
}]
|
||||||
: []),
|
: []),
|
||||||
...(hasPermission('department_leave_requests', 'view')
|
...(hasPermission('department_leave_requests', 'view')
|
||||||
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
|
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
|
||||||
|
|||||||
409
frontend/src/app/portal/managed-expense-claims/page.tsx
Normal file
409
frontend/src/app/portal/managed-expense-claims/page.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
|
return d.toLocaleDateString('en-CA');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'قيد المراجعة';
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'مقبول';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'مرفوض';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClasses(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManagedExpenseClaimsPage() {
|
||||||
|
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('PENDING');
|
||||||
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
|
const [selectedClaim, setSelectedClaim] = useState<ExpenseClaim | null>(null);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const claimId = searchParams.get('claimId');
|
||||||
|
|
||||||
|
async function loadClaims(status = statusFilter) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await portalAPI.getManagedExpenseClaims(
|
||||||
|
status === 'all' ? undefined : status
|
||||||
|
);
|
||||||
|
setClaims(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.response?.data?.message || 'تعذر تحميل طلبات كشف المصاريف');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadClaims(statusFilter);
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
async function handleApprove(id: string) {
|
||||||
|
const confirmed = window.confirm('هل أنت متأكد من الموافقة على طلب كشف المصاريف؟');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmittingId(id);
|
||||||
|
await portalAPI.approveManagedExpenseClaim(id);
|
||||||
|
await loadClaims(statusFilter);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||||
|
} finally {
|
||||||
|
setSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRejectModal(claim: ExpenseClaim) {
|
||||||
|
setSelectedClaim(claim);
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedClaim) return;
|
||||||
|
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
alert('سبب الرفض مطلوب');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmittingId(selectedClaim.id);
|
||||||
|
await portalAPI.rejectManagedExpenseClaim(
|
||||||
|
selectedClaim.id,
|
||||||
|
rejectReason.trim()
|
||||||
|
);
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
setSelectedClaim(null);
|
||||||
|
setRejectReason('');
|
||||||
|
await loadClaims(statusFilter);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
||||||
|
} finally {
|
||||||
|
setSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" dir="rtl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
طلبات كشف المصاريف للقسم
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
يمكنك مراجعة طلبات كشف المصاريف والموافقة عليها أو رفضها
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600">الحالة:</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="PENDING">قيد المراجعة</option>
|
||||||
|
<option value="APPROVED">مقبول</option>
|
||||||
|
<option value="REJECTED">مرفوض</option>
|
||||||
|
<option value="all">الكل</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-white shadow-sm">
|
||||||
|
<div className="border-b px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">الطلبات</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
|
||||||
|
) : claims.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500">
|
||||||
|
لا توجد طلبات ضمن هذا الفلتر
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{claims.map((claim) => {
|
||||||
|
const employeeName = claim.employee
|
||||||
|
? `${claim.employee.firstName} ${claim.employee.lastName}`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const isBusy = submittingId === claim.id;
|
||||||
|
const isSelected = claim.id === claimId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={claim.id}
|
||||||
|
className={`p-5 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="space-y-3 w-full">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-base font-bold text-gray-900">
|
||||||
|
{claim.claimNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
|
||||||
|
claim.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getStatusLabel(claim.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
الموظف:
|
||||||
|
</span>{' '}
|
||||||
|
{employeeName}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
الرقم الوظيفي:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.employee?.uniqueEmployeeId || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
إجمالي المبلغ:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.totalAmount ?? claim.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
تاريخ الإنشاء:
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(claim.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
عدد البنود:
|
||||||
|
</span>{' '}
|
||||||
|
{Array.isArray(claim.items)
|
||||||
|
? claim.items.length
|
||||||
|
: claim.description
|
||||||
|
? 1
|
||||||
|
: 0}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
آخر تحديث:
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(claim.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claim.description ? (
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
ملاحظات عامة:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Array.isArray(claim.items) && claim.items.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="text-sm font-medium text-gray-800">
|
||||||
|
البنود:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{claim.items.map((item: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">التاريخ:</span>{' '}
|
||||||
|
{formatDate(item.expenseDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">المبلغ:</span>{' '}
|
||||||
|
{item.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">اسم الجهة:</span>{' '}
|
||||||
|
{item.entityName || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
المشروع / المناقصة:
|
||||||
|
</span>{' '}
|
||||||
|
{item.projectOrTender || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
الأوراق المثبتة:
|
||||||
|
</span>{' '}
|
||||||
|
{item.proofRef || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="font-medium">البيان:</span>{' '}
|
||||||
|
{item.description || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">تاريخ المصروف:</span>{' '}
|
||||||
|
{formatDate(claim.expenseDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">المبلغ:</span>{' '}
|
||||||
|
{claim.amount ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
المشروع / المناقصة:
|
||||||
|
</span>{' '}
|
||||||
|
{claim.projectOrTender || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">البيان:</span>{' '}
|
||||||
|
{claim.description || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{claim.status === 'REJECTED' && claim.rejectedReason ? (
|
||||||
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
<span className="font-medium">سبب الرفض:</span>{' '}
|
||||||
|
{claim.rejectedReason}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{claim.status === 'PENDING' ? (
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(claim.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isBusy ? 'جارٍ التنفيذ...' : 'موافقة'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openRejectModal(claim)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
رفض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={rejectModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
setSelectedClaim(null);
|
||||||
|
setRejectReason('');
|
||||||
|
}}
|
||||||
|
title="رفض طلب كشف المصاريف"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleRejectSubmit} className="space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{selectedClaim ? (
|
||||||
|
<>
|
||||||
|
سيتم رفض الطلب:{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{selectedClaim.claimNumber}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
سبب الرفض
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
className="min-h-[120px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-red-500"
|
||||||
|
placeholder="اكتب سبب الرفض"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
setSelectedClaim(null);
|
||||||
|
setRejectReason('');
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedClaim || submittingId === selectedClaim?.id}
|
||||||
|
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{selectedClaim && submittingId === selectedClaim.id
|
||||||
|
? 'جارٍ التنفيذ...'
|
||||||
|
: 'تأكيد الرفض'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import Link from 'next/link'
|
|||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
|
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2,FileText } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export default function PortalDashboardPage() {
|
export default function PortalDashboardPage() {
|
||||||
@@ -25,7 +25,7 @@ export default function PortalDashboardPage() {
|
|||||||
|
|
||||||
const { employee, stats } = data
|
const { employee, stats } = data
|
||||||
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canViewDepartmentExpenseClaims = hasPermission('department_expense_claims', 'view')
|
||||||
const name = employee.firstNameAr && employee.lastNameAr
|
const name = employee.firstNameAr && employee.lastNameAr
|
||||||
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
||||||
: `${employee.firstName} ${employee.lastName}`
|
: `${employee.firstName} ${employee.lastName}`
|
||||||
@@ -89,12 +89,27 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</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-2xl font-bold text-fuchsia-600 mt-1">{stats.pendingExpenseClaimsCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-fuchsia-100 p-3 rounded-lg">
|
||||||
|
<FileText className="h-6 w-6 text-fuchsia-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/portal/expense-claims" className="mt-4 text-sm text-fuchsia-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="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
|
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
|
||||||
<p className="text-2xl font-bold text-blue-600 mt-1">
|
<p className="text-2xl font-bold text-blue-600 mt-1">
|
||||||
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
|
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount + stats.pendingExpenseClaimsCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-100 p-3 rounded-lg">
|
<div className="bg-blue-100 p-3 rounded-lg">
|
||||||
@@ -104,6 +119,7 @@ export default function PortalDashboardPage() {
|
|||||||
<div className="mt-4 flex gap-4">
|
<div className="mt-4 flex gap-4">
|
||||||
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
|
<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>
|
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
|
||||||
|
<Link href="/portal/expense-claims" className="text-sm text-blue-600 hover:underline">المصاريف</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,6 +187,11 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/portal/expense-claims" className="inline-flex items-center gap-2 px-4 py-2 bg-fuchsia-600 text-white rounded-lg hover:bg-fuchsia-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">
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب شراء
|
طلب شراء
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PortalProfile {
|
|||||||
activeLoansCount: number
|
activeLoansCount: number
|
||||||
pendingLeavesCount: number
|
pendingLeavesCount: number
|
||||||
pendingPurchaseRequestsCount: number
|
pendingPurchaseRequestsCount: number
|
||||||
|
pendingExpenseClaimsCount: number
|
||||||
leaveBalance: Array<{
|
leaveBalance: Array<{
|
||||||
leaveType: string
|
leaveType: string
|
||||||
totalDays: number
|
totalDays: number
|
||||||
@@ -98,6 +99,43 @@ export interface PurchaseRequest {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExpenseClaim {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
claimNumber: string;
|
||||||
|
|
||||||
|
items?: ExpenseClaimItem[] | null;
|
||||||
|
totalAmount?: number | null;
|
||||||
|
|
||||||
|
expenseDate: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
description: string | null;
|
||||||
|
projectOrTender: string | null;
|
||||||
|
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'REJECTED' | string;
|
||||||
|
approvedBy?: string | null;
|
||||||
|
approvedAt?: string | null;
|
||||||
|
rejectedReason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
employee?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
uniqueEmployeeId?: string | null;
|
||||||
|
reportingToId?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseClaimItem {
|
||||||
|
expenseDate: string;
|
||||||
|
amount: number;
|
||||||
|
entityName?: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
@@ -235,6 +273,43 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
|
||||||
|
const response = await api.get('/hr/portal/expense-claims')
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
submitExpenseClaim: async (data: {
|
||||||
|
items: Array<{
|
||||||
|
expenseDate: string;
|
||||||
|
amount: number;
|
||||||
|
entityName?: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.post('/hr/portal/expense-claims', data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (status && status !== 'all') q.append('status', status)
|
||||||
|
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
approveManagedExpenseClaim: async (id: string): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/approve`)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/reject`, { rejectedReason })
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (month) params.append('month', String(month))
|
if (month) params.append('month', String(month))
|
||||||
|
|||||||
Reference in New Issue
Block a user