update expense-claims

This commit is contained in:
Aya
2026-05-03 10:30:03 +03:00
parent 11d14c01d2
commit 345ba195f8
11 changed files with 492 additions and 364 deletions

View File

@@ -302,8 +302,8 @@ model Leave {
employeeId String employeeId String
employee Employee @relation(fields: [employeeId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id])
leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc. leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc.
startDate DateTime @db.Date startDate DateTime @db.Timestamp(3)
endDate DateTime @db.Date endDate DateTime @db.Timestamp(3)
days Int days Int
reason String? reason String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED status String @default("PENDING") // PENDING, APPROVED, REJECTED
@@ -523,6 +523,7 @@ model ExpenseClaim {
approvedBy String? approvedBy String?
approvedAt DateTime? approvedAt DateTime?
rejectedReason String? rejectedReason String?
approvalNote String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -2,8 +2,50 @@ import { Router } from 'express';
import { hrController } from './hr.controller'; import { hrController } from './hr.controller';
import { portalController } from './portal.controller'; import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth'; import { authenticate, authorize } from '../../shared/middleware/auth';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { config } from '../../config';
const router = Router(); const router = Router();
const expenseClaimsUploadDir = path.join(config.upload.path, 'expense-claims');
if (!fs.existsSync(expenseClaimsUploadDir)) {
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
}
const expenseClaimStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
filename: (_req, file, cb) => {
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
},
});
const expenseClaimUpload = multer({
storage: expenseClaimStorage,
limits: { fileSize: config.upload.maxFileSize },
fileFilter: (_req, file, cb) => {
const allowedTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
if (!allowedTypes.includes(file.mimetype)) {
return cb(
new Error('نوع الملف غير مدعوم. يرجى رفع صورة أو ملف PDF.')
);
}
cb(null, true);
},
});
router.use(authenticate); router.use(authenticate);
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ========== // ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
@@ -60,13 +102,27 @@ 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.get('/portal/expense-claims', portalController.getMyExpenseClaims);
router.post('/portal/expense-claims', portalController.submitExpenseClaim); router.post(
'/portal/expense-claims',
(req, res, next) => {
expenseClaimUpload.single('attachment')(req, res, (error: any) => {
if (error) {
return res.status(400).json({
success: false,
message: error.message || 'تعذر رفع المرفق',
});
}
router.get( next();
'/portal/managed-expense-claims', });
authorize('department_expense_claims', '*', 'read'), },
portalController.getManagedExpenseClaims portalController.submitExpenseClaim
); );
router.get(
'/portal/expense-claims/attachments/:attachmentId/view',
portalController.viewExpenseClaimAttachment
);
router.get('/portal/managed-expense-claims', authorize('department_expense_claims', '*', 'read'), portalController.getManagedExpenseClaims);
router.post( router.post(
'/portal/managed-expense-claims/:id/approve', '/portal/managed-expense-claims/:id/approve',

View File

@@ -1,7 +1,6 @@
import prisma from '../../config/database'; import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler'; import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger'; import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
class HRService { class HRService {
// ========== EMPLOYEES ========== // ========== EMPLOYEES ==========
@@ -353,40 +352,15 @@ class HRService {
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: leave.id, entityId: leave.id,
action: 'CREATE', action: 'CREATE',
userId, userId,
}); });
const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'leave_requests',
fallbackEmployeeId: leave.employeeId,
fallbackToManager: true,
type: 'LEAVE_REQUEST_SUBMITTED',
title: 'طلب إجازة جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`,
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_CREATED',
title: 'تم إرسال طلب الإجازة',
message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [],
});
return leave; return leave;
} }
async approveLeave(id: string, approvedBy: string, userId: string) { async approveLeave(id: string, approvedBy: string, userId: string) {
const leave = await prisma.leave.update({ const leave = await prisma.leave.update({
where: { id }, where: { id },
@@ -404,25 +378,16 @@ class HRService {
const year = new Date(leave.startDate).getFullYear(); const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days); await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: leave.id, entityId: leave.id,
action: 'APPROVE', action: 'APPROVE',
userId, userId,
}); });
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
return leave; return leave;
} }
async rejectLeave(id: string, rejectedReason: string, userId: string) { async rejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({ where: { id } }); const leave = await prisma.leave.findUnique({ where: { id } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found'); if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
@@ -438,26 +403,13 @@ class HRService {
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: id, entityId: id,
action: 'REJECT', action: 'REJECT',
userId, userId,
reason: rejectedReason, reason: rejectedReason,
}); });
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated; return updated;
} }
@@ -614,23 +566,13 @@ async findManagedLeaves(status?: string) {
const year = new Date(updated.startDate).getFullYear(); const year = new Date(updated.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days); await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: updated.id, entityId: updated.id,
action: 'MANAGER_APPROVE', action: 'MANAGER_APPROVE',
userId, userId,
}); });
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated; return updated;
} }
@@ -668,7 +610,7 @@ async findManagedLeaves(status?: string) {
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: updated.id, entityId: updated.id,
action: 'MANAGER_REJECT', action: 'MANAGER_REJECT',
@@ -676,18 +618,6 @@ async findManagedLeaves(status?: string) {
reason: rejectedReason, reason: rejectedReason,
}); });
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated; return updated;
} }
@@ -913,30 +843,7 @@ private isSystemAdminUser(user: any) {
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_SUBMITTED',
title: 'طلب قرض جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_CREATED',
title: 'تم إرسال طلب القرض',
message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [],
});
return loan; return loan;
} }
@@ -992,36 +899,13 @@ private isSystemAdminUser(user: any) {
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LOAN', entityType: 'LOAN',
entityId: id, entityId: id,
action: 'HR_APPROVE_FORWARD_TO_ADMIN', action: 'HR_APPROVE_FORWARD_TO_ADMIN',
userId, userId,
}); });
const fullLoan = await this.findLoanById(id);
const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_PENDING_ADMIN',
title: 'طلب قرض محال إلى مدير النظام',
message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: fullLoan.employee.id,
type: 'LOAN_REQUEST_ESCALATED',
title: 'تمت إحالة طلب القرض للاعتماد النهائي',
message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
return updatedLoan; return updatedLoan;
} }
} }
@@ -1070,26 +954,14 @@ private isSystemAdminUser(user: any) {
), ),
]); ]);
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LOAN', entityType: 'LOAN',
entityId: id, entityId: id,
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE', action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
userId, userId,
}); });
const approvedLoan = await this.findLoanById(id); return this.findLoanById(id);
await notificationsService.notifyEmployeeUser({
employeeId: approvedLoan.employee.id,
type: 'LOAN_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب القرض',
message: `تمت الموافقة على طلب القرض الخاص بك برقم ${approvedLoan.loanNumber}.`,
entityType: 'LOAN',
entityId: approvedLoan.id,
excludeUserIds: [userId],
});
return approvedLoan;
} }
async rejectLoan(id: string, rejectedReason: string, userId: string) { async rejectLoan(id: string, rejectedReason: string, userId: string) {
@@ -1109,7 +981,7 @@ private isSystemAdminUser(user: any) {
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LOAN', entityType: 'LOAN',
entityId: id, entityId: id,
action: 'REJECT', action: 'REJECT',
@@ -1117,18 +989,6 @@ private isSystemAdminUser(user: any) {
reason: rejectedReason, reason: rejectedReason,
}); });
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_REJECTED',
title: 'تم رفض طلب القرض',
message: rejectedReason?.trim()
? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}`
: 'تم رفض طلب القرض الخاص بك.',
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
return loan; return loan;
} }
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
@@ -1203,30 +1063,7 @@ private isSystemAdminUser(user: any) {
}, },
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
const employeeFullName = `${req.employee.firstName} ${req.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'purchase_requests',
type: 'PURCHASE_REQUEST_SUBMITTED',
title: 'طلب شراء جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب شراء جديد برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_CREATED',
title: 'تم إرسال طلب الشراء',
message: `تم إرسال طلب الشراء الخاص بك برقم ${req.requestNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [],
});
return req; return req;
} }
@@ -1236,19 +1073,8 @@ private isSystemAdminUser(user: any) {
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null }, data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
return req;
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الشراء',
message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req;;
} }
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
@@ -1258,19 +1084,6 @@ private isSystemAdminUser(user: any) {
include: { employee: true }, include: { employee: true },
}); });
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_REJECTED',
title: 'تم رفض طلب الشراء',
message: rejectedReason?.trim()
? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}`
: `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req; return req;
} }

View File

@@ -187,12 +187,61 @@ export class PortalController {
async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const data = await portalService.submitExpenseClaim(req.user?.employeeId, req.body, req.user!.id); const body = { ...req.body };
res.status(201).json(ResponseFormatter.success(data, 'تم إرسال كشف المصاريف - Expense claim submitted'));
} catch (error) { if (typeof body.items === 'string') {
body.items = JSON.parse(body.items);
}
const data = await portalService.submitExpenseClaim(
req.user?.employeeId,
body,
req.user!.id,
req.file as any
);
res
.status(201)
.json(
ResponseFormatter.success(
data,
'تم إرسال كشف المصاريف - Expense claim submitted'
)
);
} catch (error: any) {
if (error.message?.includes('نوع الملف غير مدعوم')) {
return res.status(400).json({
success: false,
message: error.message,
});
}
next(error); next(error);
} }
} }
async viewExpenseClaimAttachment(
req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const attachment = await portalService.getExpenseClaimAttachmentFile(
req.params.attachmentId
);
const encodedFileName = encodeURIComponent(attachment.originalName);
res.setHeader('Content-Type', attachment.mimeType);
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodedFileName}`
);
res.sendFile(attachment.path);
} catch (error) {
next(error);
}
}
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) { async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
@@ -205,13 +254,21 @@ export class PortalController {
} }
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const data = await portalService.approveManagedExpenseClaim(req.user?.employeeId, req.params.id, req.user!.id); const { approvalNote } = req.body;
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
} catch (error) { const data = await portalService.approveManagedExpenseClaim(
next(error); req.user?.employeeId,
} req.params.id,
req.user!.id,
approvalNote
);
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
} catch (error) {
next(error);
} }
}
async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try { try {

View File

@@ -2,6 +2,7 @@ import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler'; import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service'; import { hrService } from './hr.service';
import { notificationsService } from '../notifications/notifications.service'; import { notificationsService } from '../notifications/notifications.service';
import path from 'path';
class PortalService { class PortalService {
private requireEmployeeId(employeeId: string | undefined): string { private requireEmployeeId(employeeId: string | undefined): string {
@@ -242,6 +243,40 @@ class PortalService {
return this.formatOvertimeRequest(attendance); return this.formatOvertimeRequest(attendance);
} }
private async attachExpenseClaimFiles<T extends { id: string }>(claims: T[]) {
const claimIds = claims.map((claim) => claim.id);
if (claimIds.length === 0) {
return claims.map((claim) => ({ ...claim, attachments: [] }));
}
const attachments = await prisma.attachment.findMany({
where: {
entityType: 'EXPENSE_CLAIM',
entityId: { in: claimIds },
},
orderBy: { uploadedAt: 'desc' },
});
return claims.map((claim) => ({
...claim,
attachments: attachments.filter((attachment) => attachment.entityId === claim.id),
}));
}
async getExpenseClaimAttachmentFile(attachmentId: string) {
const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId },
});
if (!attachment || attachment.entityType !== 'EXPENSE_CLAIM') {
throw new AppError(404, 'الملف غير موجود');
}
return attachment;
}
async getManagedOvertimeRequests(employeeId: string | undefined) { async getManagedOvertimeRequests(employeeId: string | undefined) {
this.requireEmployeeId(employeeId); this.requireEmployeeId(employeeId);
@@ -522,39 +557,43 @@ class PortalService {
} }
async getMyExpenseClaims(employeeId: string | undefined) { async getMyExpenseClaims(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId); const empId = this.requireEmployeeId(employeeId);
return prisma.expenseClaim.findMany({ const claims = await prisma.expenseClaim.findMany({
where: { employeeId: empId }, where: { employeeId: empId },
include: { include: {
employee: { employee: {
select: { select: {
id: true, id: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
uniqueEmployeeId: 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 orderBy: { createdAt: 'desc' },
) { });
return this.attachExpenseClaimFiles(claims);
}
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,
file?: Express.Multer.File
) {
const empId = this.requireEmployeeId(employeeId); const empId = this.requireEmployeeId(employeeId);
const items = Array.isArray(data.items) ? data.items : []; const items = Array.isArray(data.items) ? data.items : [];
@@ -604,6 +643,23 @@ class PortalService {
}, },
}); });
if (file) {
const fileName = path.basename(file.path);
await prisma.attachment.create({
data: {
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
fileName,
originalName: (file as any).decodedOriginalName || file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: 'EXPENSE_CLAIM_ATTACHMENT',
uploadedBy: userId,
},
});
}
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`; const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
await notificationsService.notifyUsersWithPermission({ await notificationsService.notifyUsersWithPermission({
@@ -627,20 +683,20 @@ class PortalService {
excludeUserIds: [], excludeUserIds: [],
}); });
return claim; const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]);
return claimWithAttachments;
} }
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) { async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
this.requireEmployeeId(employeeId); this.requireEmployeeId(employeeId);
const where: any = {}; const where: any = {};
if (status && status !== 'all') { if (status && status !== 'all') {
where.status = status; where.status = status;
} else {
where.status = 'PENDING';
} }
return prisma.expenseClaim.findMany({ const claims = await prisma.expenseClaim.findMany({
where, where,
include: { include: {
employee: { employee: {
@@ -655,12 +711,14 @@ class PortalService {
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
}
return this.attachExpenseClaimFiles(claims);
}
async approveManagedExpenseClaim( async approveManagedExpenseClaim(
managerEmployeeId: string | undefined, managerEmployeeId: string | undefined,
claimId: string, claimId: string,
userId: string userId: string,
approvalNote?: string,
) { ) {
this.requireEmployeeId(managerEmployeeId); this.requireEmployeeId(managerEmployeeId);
@@ -693,6 +751,7 @@ class PortalService {
approvedBy: userId, approvedBy: userId,
approvedAt: new Date(), approvedAt: new Date(),
rejectedReason: null, rejectedReason: null,
approvalNote: approvalNote?.trim()?.slice(0, 1000) || null,
}, },
include: { include: {
employee: { employee: {
@@ -705,12 +764,12 @@ class PortalService {
}, },
}, },
}); });
const note = approvalNote?.trim()?.slice(0, 1000);
await notificationsService.notifyEmployeeUser({ await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId, employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_APPROVED', type: 'EXPENSE_CLAIM_APPROVED',
title: 'تمت الموافقة على كشف المصاريف', title: 'تمت الموافقة على كشف المصاريف',
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.`, message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.${note ? ` ملاحظة المعتمِد: ${note}` : ''}`,
entityType: 'EXPENSE_CLAIM', entityType: 'EXPENSE_CLAIM',
entityId: claim.id, entityId: claim.id,
excludeUserIds: [userId], excludeUserIds: [userId],

View File

@@ -1,7 +1,6 @@
import prisma from '../../config/database'; import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler'; import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger'; import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import path from 'path'; import path from 'path';
import fs from 'fs' import fs from 'fs'
@@ -209,25 +208,11 @@ class TendersService {
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
} }
private getComputedTenderStatus<T extends { status?: string | null; closingDate?: Date | string | null }>(tender: T) { private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
if (tender.status === 'CONVERTED_TO_DEAL' || tender.status === 'CANCELLED') {
return tender.status;
}
if (tender.closingDate && new Date(tender.closingDate) < new Date()) {
return 'EXPIRED';
}
return tender.status || 'ACTIVE';
}
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any; status?: string | null; closingDate?: Date | string | null }>(tender: T) {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
return { return {
...tender, ...tender,
status: this.getComputedTenderStatus(tender),
originalStatus: tender.status,
notes: cleanNotes || null, notes: cleanNotes || null,
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
finalBondValue: meta.finalBondValue ?? null, finalBondValue: meta.finalBondValue ?? null,
@@ -360,9 +345,7 @@ class TendersService {
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, { issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
]; ];
} }
if (filters.status && filters.status !== 'EXPIRED') { if (filters.status) where.status = filters.status;
where.status = filters.status;
}
if (filters.source) where.source = filters.source; if (filters.source) where.source = filters.source;
if (filters.announcementType) where.announcementType = filters.announcementType; if (filters.announcementType) where.announcementType = filters.announcementType;
@@ -378,15 +361,9 @@ class TendersService {
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t)); return {
const filteredTenders = tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
filters.status === 'EXPIRED' total,
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
: mappedTenders;
return {
tenders: filteredTenders,
total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
page, page,
pageSize, pageSize,
}; };
@@ -540,20 +517,20 @@ class TendersService {
}, },
}); });
const assignedUser = directive.assignedToEmployee?.user; const assignedUser = directive.assignedToEmployee?.user;
if (assignedUser?.id) { if (assignedUser?.id) {
const typeLabel = this.getDirectiveTypeLabel(data.type); const typeLabel = this.getDirectiveTypeLabel(data.type);
await prisma.notification.create({
await notificationsService.notifyMany({ data: {
userIds: [assignedUser.id], userId: assignedUser.id,
type: 'TENDER_DIRECTIVE_ASSIGNED', type: 'TENDER_DIRECTIVE_ASSIGNED',
title: 'تم إسناد توجيه مناقصة جديد', title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`, message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
entityType: 'TENDER', entityType: 'TENDER_DIRECTIVE',
entityId: tender.id, entityId: directive.id,
excludeUserIds: [userId], },
}); });
} }
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
@@ -701,17 +678,7 @@ class TendersService {
return deal; return deal;
} }
private decodeUploadedFileName(fileName: string) { async uploadTenderAttachment(
if (!fileName) return 'file';
try {
return Buffer.from(fileName, 'latin1').toString('utf8');
} catch {
return fileName;
}
}
async uploadTenderAttachment(
tenderId: string, tenderId: string,
file: { path: string; originalname: string; mimetype: string; size: number }, file: { path: string; originalname: string; mimetype: string; size: number },
userId: string, userId: string,
@@ -719,17 +686,14 @@ class TendersService {
) { ) {
const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
if (!tender) throw new AppError(404, 'Tender not found'); if (!tender) throw new AppError(404, 'Tender not found');
const fileName = path.basename(file.path); const fileName = path.basename(file.path);
const originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
tenderId, tenderId,
fileName, fileName,
originalName, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -737,7 +701,6 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
@@ -745,7 +708,6 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
@@ -759,12 +721,8 @@ class TendersService {
where: { id: directiveId }, where: { id: directiveId },
select: { id: true, tenderId: true }, select: { id: true, tenderId: true },
}); });
if (!directive) throw new AppError(404, 'Directive not found'); if (!directive) throw new AppError(404, 'Directive not found');
const fileName = path.basename(file.path); const fileName = path.basename(file.path);
const originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
@@ -772,7 +730,7 @@ class TendersService {
tenderDirectiveId: directiveId, tenderDirectiveId: directiveId,
tenderId: directive.tenderId, tenderId: directive.tenderId,
fileName, fileName,
originalName, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -780,7 +738,6 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
entityId: directiveId, entityId: directiveId,
@@ -788,9 +745,9 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
async getAttachmentFile(attachmentId: string): Promise<string> { async getAttachmentFile(attachmentId: string): Promise<string> {
const attachment = await prisma.attachment.findUnique({ const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId }, where: { id: attachmentId },
@@ -808,10 +765,12 @@ class TendersService {
if (!attachment) throw new AppError(404, 'File not found') if (!attachment) throw new AppError(404, 'File not found')
// حذف من الديسك
if (attachment.path && fs.existsSync(attachment.path)) { if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path) fs.unlinkSync(attachment.path)
} }
// حذف من DB
await prisma.attachment.delete({ await prisma.attachment.delete({
where: { id: attachmentId }, where: { id: attachmentId },
}) })

View File

@@ -9,7 +9,6 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes'; import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes'; import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes'; import tendersRoutes from '../modules/tenders/tenders.routes';
import notificationsRoutes from '../modules/notifications/notifications.routes';
const router = Router(); const router = Router();
@@ -24,7 +23,6 @@ router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes); router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes); router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes); router.use('/tenders', tendersRoutes);
router.use('/notifications', notificationsRoutes);
// API info // API info
router.get('/', (req, res) => { router.get('/', (req, res) => {

View File

@@ -17,6 +17,7 @@ type ExpenseClaimLine = {
type ExpenseClaimFormState = { type ExpenseClaimFormState = {
items: ExpenseClaimLine[]; items: ExpenseClaimLine[];
description: string; description: string;
attachment: File | null;
}; };
const emptyLine = (): ExpenseClaimLine => ({ const emptyLine = (): ExpenseClaimLine => ({
@@ -31,6 +32,7 @@ const emptyLine = (): ExpenseClaimLine => ({
const initialForm: ExpenseClaimFormState = { const initialForm: ExpenseClaimFormState = {
items: [emptyLine()], items: [emptyLine()],
description: '', description: '',
attachment: null,
}; };
function getStatusLabel(status: string) { function getStatusLabel(status: string) {
@@ -74,10 +76,11 @@ export default function PortalExpenseClaimsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm); const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
const pendingCount = useMemo( const [statusFilter, setStatusFilter] = useState('all');
() => claims.filter((c) => c.status === 'PENDING').length, const filteredClaims = useMemo(() => {
[claims] if (statusFilter === 'all') return claims;
); return claims.filter((claim) => claim.status === statusFilter);
}, [claims, statusFilter]);
const totalAmount = useMemo(() => { const totalAmount = useMemo(() => {
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0); return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
@@ -127,6 +130,24 @@ export default function PortalExpenseClaimsPage() {
})); }));
}; };
async function openAttachment(attachment: any) {
try {
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
const blobUrl = window.URL.createObjectURL(
new Blob([blob], { type: attachment.mimeType })
);
window.open(blobUrl, '_blank');
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, 10000);
} catch (error) {
alert('تعذر فتح المرفق');
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
@@ -157,6 +178,8 @@ export default function PortalExpenseClaimsPage() {
await portalAPI.submitExpenseClaim({ await portalAPI.submitExpenseClaim({
items, items,
description: form.description.trim() || undefined, description: form.description.trim() || undefined,
attachment: form.attachment,
}); });
setForm(initialForm); setForm(initialForm);
@@ -198,12 +221,16 @@ export default function PortalExpenseClaimsPage() {
</div> </div>
</div> </div>
<div className="rounded-xl border bg-white p-5 shadow-sm"> <select
<div className="text-sm text-gray-500">قيد المراجعة</div> value={statusFilter}
<div className="mt-2 text-2xl font-bold text-yellow-600"> onChange={(e) => setStatusFilter(e.target.value)}
{pendingCount} className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
</div> >
</div> <option value="all">الكل</option>
<option value="PENDING">قيد المراجعة</option>
<option value="APPROVED">مقبول</option>
<option value="REJECTED">مرفوض</option>
</select>
<div className="rounded-xl border bg-white p-5 shadow-sm"> <div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">آخر تحديث</div> <div className="text-sm text-gray-500">آخر تحديث</div>
@@ -222,14 +249,14 @@ export default function PortalExpenseClaimsPage() {
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div> <div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
) : error ? ( ) : error ? (
<div className="p-6 text-sm text-red-600">{error}</div> <div className="p-6 text-sm text-red-600">{error}</div>
) : claims.length === 0 ? ( ) : filteredClaims.length === 0 ? (
<div className="p-6 text-sm text-gray-500"> <div className="p-6 text-sm text-gray-500">
لا توجد طلبات كشف مصاريف حالياً لا توجد طلبات كشف مصاريف حالياً
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{claims.map((claim) => { {filteredClaims.map((claim) => {
const isSelected = claim.id === claimId; const isSelected = claim.id === claimId;
return ( return (
<div <div
@@ -249,9 +276,15 @@ export default function PortalExpenseClaimsPage() {
claim.status claim.status
)}`} )}`}
> >
{getStatusLabel(claim.status)} {getStatusLabel(claim.status)}
</span> </span>
</div> </div>
{claim.status === 'APPROVED' && claim.approvalNote ? (
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
<span className="font-medium">ملاحظة المعتمِد:</span> {claim.approvalNote}
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
<div> <div>
@@ -333,6 +366,24 @@ export default function PortalExpenseClaimsPage() {
</span>{' '} </span>{' '}
{item.proofRef || '-'} {item.proofRef || '-'}
</div> </div>
{claim.attachments && claim.attachments.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
<div className="space-y-1">
{claim.attachments.map((attachment) => (
<button
key={attachment.id}
type="button"
onClick={() => openAttachment(attachment)}
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
>
{attachment.originalName}
</button>
))}
</div>
</div>
) : null}
<div className="md:col-span-2"> <div className="md:col-span-2">
<span className="font-medium">البيان:</span>{' '} <span className="font-medium">البيان:</span>{' '}
{item.description || '-'} {item.description || '-'}
@@ -510,6 +561,36 @@ export default function PortalExpenseClaimsPage() {
placeholder="أي ملاحظات عامة على الكشف" placeholder="أي ملاحظات عامة على الكشف"
/> />
</div> </div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
مرفق
</label>
<input
type="file"
accept="image/*,application/pdf"
onChange={(e) =>
setForm((prev) => ({
...prev,
attachment: e.target.files?.[0] || null,
}))
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
{form.attachment ? (
<div className="mt-2 flex items-center justify-between rounded-lg bg-gray-50 border px-3 py-2 text-sm text-gray-700">
<span>{form.attachment.name}</span>
<button
type="button"
onClick={() => setForm((prev) => ({ ...prev, attachment: null }))}
className="text-red-600 hover:underline"
>
إزالة
</button>
</div>
) : null}
</div>
<div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700"> <div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700">
الإجمالي: {totalAmount.toLocaleString()} الإجمالي: {totalAmount.toLocaleString()}

View File

@@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
const hour = Math.floor(i / 2).toString().padStart(2, '0')
const minute = i % 2 === 0 ? '00' : '30'
return `${hour}:${minute}`
})
const LEAVE_TYPES = [ const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' }, { value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'HOURLY', label: 'إجازة ساعية' }, { value: 'HOURLY', label: 'إجازة ساعية' },
@@ -47,6 +53,9 @@ export default function PortalLeavePage() {
} }
useEffect(() => load(), []) useEffect(() => load(), [])
const toCompanyDateTime = (date: string, time: string) => {
return `${date}T${time}:00+03:00`
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -80,8 +89,8 @@ export default function PortalLeavePage() {
return return
} }
payload.startDate = `${form.leaveDate}T${form.startTime}:00` payload.startDate = `${form.leaveDate}T${form.startTime}:00+03:00`
payload.endDate = `${form.leaveDate}T${form.endTime}:00` payload.endDate = `${form.leaveDate}T${form.endTime}:00+03:00`
} }
setSubmitting(true) setSubmitting(true)
@@ -238,22 +247,30 @@ export default function PortalLeavePage() {
<div> <div>
<label className="text-sm">من الساعة</label> <label className="text-sm">من الساعة</label>
<input <select
type="time"
value={form.startTime} value={form.startTime}
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
className="border p-2 rounded w-full" className="border p-2 rounded w-full"
/> >
<option value="">اختر الوقت</option>
{TIME_OPTIONS.map((time) => (
<option key={time} value={time}>{time}</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-sm">إلى الساعة</label> <label className="text-sm">إلى الساعة</label>
<input <select
type="time"
value={form.endTime} value={form.endTime}
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
className="border p-2 rounded w-full" className="border p-2 rounded w-full"
/> >
<option value="">اختر الوقت</option>
{TIME_OPTIONS.map((time) => (
<option key={time} value={time}>{time}</option>
))}
</select>
</div> </div>
</div> </div>
)} )}

View File

@@ -69,13 +69,33 @@ export default function ManagedExpenseClaimsPage() {
loadClaims(statusFilter); loadClaims(statusFilter);
}, [statusFilter]); }, [statusFilter]);
async function openAttachment(attachment: any) {
try {
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
const blobUrl = window.URL.createObjectURL(
new Blob([blob], { type: attachment.mimeType })
);
window.open(blobUrl, '_blank');
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, 10000);
} catch (error) {
alert('تعذر فتح المرفق');
}
}
async function handleApprove(id: string) { async function handleApprove(id: string) {
const confirmed = window.confirm('هل أنت متأكد من الموافقة على طلب كشف المصاريف؟'); const note = window.prompt(
if (!confirmed) return; 'ملاحظة مع الموافقة (اتركها فارغة إذا لا توجد):',
'',
);
if (note === null) return;
try { try {
setSubmittingId(id); setSubmittingId(id);
await portalAPI.approveManagedExpenseClaim(id); await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
await loadClaims(statusFilter); await loadClaims(statusFilter);
} catch (error: any) { } catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة'); alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
@@ -310,6 +330,27 @@ export default function ManagedExpenseClaimsPage() {
</div> </div>
)} )}
{claim.attachments && claim.attachments.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
<div className="space-y-1">
{claim.attachments.map((attachment) => (
<a
key={attachment.id}
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
target="_blank"
rel="noreferrer"
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
>
{attachment.originalName}
</a>
))}
</div>
</div>
) : null}
{claim.status === 'REJECTED' && claim.rejectedReason ? ( {claim.status === 'REJECTED' && claim.rejectedReason ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">سبب الرفض:</span>{' '} <span className="font-medium">سبب الرفض:</span>{' '}
@@ -317,6 +358,13 @@ export default function ManagedExpenseClaimsPage() {
</div> </div>
) : null} ) : null}
{claim.status === 'APPROVED' && claim.approvalNote ? (
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
<span className="font-medium">ملاحظة المعتمِد:</span>{' '}
{claim.approvalNote}
</div>
) : null}
{claim.status === 'PENDING' ? ( {claim.status === 'PENDING' ? (
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<button <button

View File

@@ -116,8 +116,17 @@ export interface ExpenseClaim {
approvedBy?: string | null; approvedBy?: string | null;
approvedAt?: string | null; approvedAt?: string | null;
rejectedReason?: string | null; rejectedReason?: string | null;
approvalNote?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
attachments?: Array<{
id: string;
fileName: string;
originalName: string;
mimeType: string;
size: number;
uploadedAt: string;
}> | null;
employee?: { employee?: {
id: string; id: string;
firstName: string; firstName: string;
@@ -181,6 +190,17 @@ export interface Salary {
export const portalAPI = { export const portalAPI = {
viewExpenseClaimAttachment: async (attachmentId: string): Promise<Blob> => {
const response = await api.get(
`/hr/portal/expense-claims/attachments/${attachmentId}/view`,
{
responseType: 'blob',
}
);
return response.data;
},
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => { getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
const q = new URLSearchParams() const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status) if (status && status !== 'all') q.append('status', status)
@@ -278,20 +298,36 @@ export const portalAPI = {
return response.data.data || [] return response.data.data || []
}, },
submitExpenseClaim: async (data: { submitExpenseClaim: async (data: {
items: Array<{ items: Array<{
expenseDate: string; expenseDate: string;
amount: number; amount: number;
entityName?: string; entityName?: string;
description: string; description: string;
projectOrTender?: string; projectOrTender?: string;
proofRef?: string; proofRef?: string;
}>; }>;
description?: string; description?: string;
}): Promise<ExpenseClaim> => { attachment?: File | null;
const response = await api.post('/hr/portal/expense-claims', data); }): Promise<ExpenseClaim> => {
return response.data.data; const formData = new FormData();
},
formData.append('items', JSON.stringify(data.items));
if (data.description) {
formData.append('description', data.description);
}
if (data.attachment) {
formData.append('attachment', data.attachment);
}
const response = await api.post('/hr/portal/expense-claims', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data.data;
},
getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => { getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => {
const q = new URLSearchParams() const q = new URLSearchParams()
@@ -300,9 +336,12 @@ export const portalAPI = {
return response.data.data || [] return response.data.data || []
}, },
approveManagedExpenseClaim: async (id: string): Promise<ExpenseClaim> => { approveManagedExpenseClaim: async (id: string, approvalNote?: string): Promise<ExpenseClaim> => {
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/approve`) const response = await api.post(
return response.data.data `/hr/portal/managed-expense-claims/${id}/approve`,
approvalNote?.trim() ? { approvalNote: approvalNote.trim() } : {},
);
return response.data.data;
}, },
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => { rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {