update expense-claims
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 || 'تعذر تنفيذ الموافقة');
|
||||||
@@ -309,6 +329,27 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user