From 0a9e1bbd4dd7f60165fd9ad2a67960b1a409e192 Mon Sep 17 00:00:00 2001 From: Aya Date: Wed, 22 Apr 2026 11:36:47 +0300 Subject: [PATCH] addition expense claims --- .../migration.sql | 26 + .../migration.sql | 9 + backend/prisma/schema.prisma | 29 + backend/src/modules/hr/hr.routes.ts | 21 + backend/src/modules/hr/portal.controller.ts | 52 ++ backend/src/modules/hr/portal.service.ts | 294 +++++++++- .../notifications/notifications.service.ts | 4 +- .../src/app/admin/permission-groups/page.tsx | 3 +- frontend/src/app/admin/roles/page.tsx | 1 + frontend/src/app/dashboard/page.tsx | 61 +- frontend/src/app/hr/page.tsx | 12 +- .../src/app/portal/expense-claims/page.tsx | 539 ++++++++++++++++++ frontend/src/app/portal/layout.tsx | 22 +- .../portal/managed-expense-claims/page.tsx | 409 +++++++++++++ frontend/src/app/portal/page.tsx | 27 +- frontend/src/lib/api/portal.ts | 75 +++ 16 files changed, 1553 insertions(+), 31 deletions(-) create mode 100644 backend/prisma/migrations/20260420110000_add_expense_claims/migration.sql create mode 100644 backend/prisma/migrations/20260420150000_add_expense_claim_items/migration.sql create mode 100644 frontend/src/app/portal/expense-claims/page.tsx create mode 100644 frontend/src/app/portal/managed-expense-claims/page.tsx diff --git a/backend/prisma/migrations/20260420110000_add_expense_claims/migration.sql b/backend/prisma/migrations/20260420110000_add_expense_claims/migration.sql new file mode 100644 index 0000000..c30f62f --- /dev/null +++ b/backend/prisma/migrations/20260420110000_add_expense_claims/migration.sql @@ -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; \ No newline at end of file diff --git a/backend/prisma/migrations/20260420150000_add_expense_claim_items/migration.sql b/backend/prisma/migrations/20260420150000_add_expense_claim_items/migration.sql new file mode 100644 index 0000000..3c5897e --- /dev/null +++ b/backend/prisma/migrations/20260420150000_add_expense_claim_items/migration.sql @@ -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; \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6738565..ba70beb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -200,6 +200,7 @@ model Employee { commissions Commission[] loans Loan[] purchaseRequests PurchaseRequest[] + expenseClaims ExpenseClaim[] leaveEntitlements LeaveEntitlement[] employeeContracts EmployeeContract[] tenderDirectivesAssigned TenderDirective[] @@ -503,6 +504,34 @@ model PurchaseRequest { @@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 { id String @id @default(uuid()) employeeId String diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 8ba7162..e19dacc 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -59,6 +59,27 @@ router.post('/portal/purchase-requests', portalController.submitPurchaseRequest) router.get('/portal/attendance', portalController.getMyAttendance); 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 ========== router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index bb3618f..5080f33 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -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) { try { const month = req.query.month ? parseInt(req.query.month as string) : undefined; diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index e3dc158..0abeb7a 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -32,10 +32,11 @@ class PortalService { 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.leave.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()), ]); @@ -45,6 +46,7 @@ class PortalService { activeLoansCount: loansCount, pendingLeavesCount: pendingLeaves, pendingPurchaseRequestsCount: pendingPurchaseRequests, + pendingExpenseClaimsCount: pendingExpenseClaims, leaveBalance, }, }; @@ -492,6 +494,296 @@ class PortalService { return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId); } + private async generateExpenseClaimNumber(): Promise { + 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) { const empId = this.requireEmployeeId(employeeId); const now = new Date(); diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 1ecdb4a..431f67b 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -222,7 +222,7 @@ class NotificationsService { } 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; fallbackToManager?: boolean; excludeUserIds?: string[]; @@ -260,7 +260,7 @@ class NotificationsService { } 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; fallbackToManager?: boolean; type: string; diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx index 9b4b7b2..2691e94 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -10,7 +10,7 @@ import LoadingSpinner from '@/components/LoadingSpinner'; const MODULES = [ { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' }, - { id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, + { id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, @@ -23,6 +23,7 @@ const MODULES = [ { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave 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: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index 4ef25eb..eb2a98d 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -24,6 +24,7 @@ const MODULES = [ { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave 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: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 74cb6c8..5eab66b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -95,23 +95,23 @@ function DashboardContent() { const resolveNotificationUrl = (notification: any) => { if (notification.entityType === 'LEAVE') { 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.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.type === 'PURCHASE_REQUEST_SUBMITTED') { - return '/hr?tab=purchases' + return '/hr?tab=purchases'; } - return '/portal/purchase-requests' + return '/portal/purchase-requests'; } if (notification.entityType === 'LOAN') { @@ -119,31 +119,68 @@ function DashboardContent() { notification.type === 'LOAN_REQUEST_SUBMITTED' || 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.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) => { + try { if (!notification.isRead) { await markNotificationAsRead(notification.id) } const targetUrl = resolveNotificationUrl(notification) + + console.log('🔔 Notification click →', notification) + console.log('➡️ Redirecting to:', targetUrl) + setShowNotifications(false) + router.push(targetUrl) + + setTimeout(() => { + window.location.href = targetUrl + }, 100) + + } catch (err) { + console.error('Notification click error:', err) } +} const handleToggleNotifications = async () => { const next = !showNotifications diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx index 7c828d5..cd33bce 100644 --- a/frontend/src/app/hr/page.tsx +++ b/frontend/src/app/hr/page.tsx @@ -761,7 +761,7 @@ type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'pu + + +
+
+
إجمالي الطلبات
+
+ {claims.length} +
+
+ +
+
قيد المراجعة
+
+ {pendingCount} +
+
+ +
+
آخر تحديث
+
+ {claims[0]?.updatedAt ? formatDate(claims[0].updatedAt) : '-'} +
+
+
+ +
+
+

طلباتي

+
+ + {loading ? ( +
جاري التحميل...
+ ) : error ? ( +
{error}
+ ) : claims.length === 0 ? ( +
+ لا توجد طلبات كشف مصاريف حالياً +
+ ) : ( +
+ {claims.map((claim) => { + const isSelected = claim.id === claimId; + + return ( +
+
+
+
+ + {claim.claimNumber} + + + {getStatusLabel(claim.status)} + +
+ +
+
+ + إجمالي المبلغ: + {' '} + {claim.totalAmount ?? claim.amount ?? '-'} +
+ +
+ + تاريخ الإنشاء: + {' '} + {formatDate(claim.createdAt)} +
+ +
+ + عدد البنود: + {' '} + {Array.isArray(claim.items) + ? claim.items.length + : claim.description + ? 1 + : 0} +
+ +
+ + آخر تحديث: + {' '} + {formatDate(claim.updatedAt)} +
+
+ + {claim.description ? ( +
+ + ملاحظات عامة: + {' '} + {claim.description} +
+ ) : null} + + {Array.isArray(claim.items) && claim.items.length > 0 ? ( +
+
+ البنود: +
+ +
+ {claim.items.map((item: any, idx: number) => ( +
+
+
+ التاريخ:{' '} + {formatDate(item.expenseDate)} +
+
+ المبلغ:{' '} + {item.amount ?? '-'} +
+
+ اسم الجهة:{' '} + {item.entityName || '-'} +
+
+ + المشروع / المناقصة: + {' '} + {item.projectOrTender || '-'} +
+
+ + الأوراق المثبتة: + {' '} + {item.proofRef || '-'} +
+
+ البيان:{' '} + {item.description || '-'} +
+
+
+ ))} +
+
+ ) : ( +
+
+ تاريخ المصروف:{' '} + {formatDate(claim.expenseDate)} +
+
+ المبلغ:{' '} + {claim.amount ?? '-'} +
+
+ + المشروع / المناقصة: + {' '} + {claim.projectOrTender || '-'} +
+
+ البيان:{' '} + {claim.description || '-'} +
+
+ )} + + {claim.status === 'REJECTED' && claim.rejectedReason ? ( +
+ سبب الرفض:{' '} + {claim.rejectedReason} +
+ ) : null} +
+
+
+ ); + })} +
+ )} +
+ + setShowModal(false)} + title="كشف مصاريف جديد" + > +
+
+
+ + + +
+ +
+ {form.items.map((item, index) => ( +
+
+
+ + + updateItem(index, 'expenseDate', e.target.value) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ +
+ + + updateItem(index, 'amount', e.target.value) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ +
+ + + updateItem(index, 'entityName', e.target.value) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ +
+ + + updateItem(index, 'projectOrTender', e.target.value) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ +
+ +