addition expense claims

This commit is contained in:
Aya
2026-04-22 11:36:47 +03:00
parent e262d8c09c
commit 0a9e1bbd4d
16 changed files with 1553 additions and 31 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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' },

View File

@@ -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' },

View File

@@ -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 () => {

View File

@@ -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'

View 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>
);
}

View File

@@ -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' }]

View 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>
);
}

View File

@@ -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" />
طلب شراء طلب شراء

View File

@@ -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))