edit for portal & tender
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- Add issue_number column for tenders + supporting index
|
||||
ALTER TABLE "tenders" ADD COLUMN "issueNumber" TEXT;
|
||||
CREATE INDEX "tenders_issueNumber_idx" ON "tenders"("issueNumber");
|
||||
@@ -921,6 +921,7 @@ model Invoice {
|
||||
model Tender {
|
||||
id String @id @default(uuid())
|
||||
tenderNumber String @unique
|
||||
issueNumber String?
|
||||
issuingBodyName String
|
||||
title String
|
||||
termsValue Decimal @db.Decimal(15, 2)
|
||||
@@ -943,6 +944,7 @@ model Tender {
|
||||
attachments Attachment[]
|
||||
convertedDeal Deal?
|
||||
@@index([tenderNumber])
|
||||
@@index([issueNumber])
|
||||
@@index([status])
|
||||
@@index([createdById])
|
||||
@@index([announcementDate])
|
||||
|
||||
@@ -68,9 +68,13 @@ router.use(authenticate);
|
||||
router.get('/portal/me', portalController.getMe);
|
||||
router.get('/portal/loans', portalController.getMyLoans);
|
||||
router.post('/portal/loans', portalController.submitLoanRequest);
|
||||
router.put('/portal/loans/:id', portalController.updateMyLoan);
|
||||
router.delete('/portal/loans/:id', portalController.deleteMyLoan);
|
||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||
router.put('/portal/leaves/:id', portalController.updateMyLeave);
|
||||
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-leaves',
|
||||
@@ -92,6 +96,8 @@ router.post(
|
||||
|
||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
||||
router.put('/portal/overtime-requests/:attendanceId', portalController.updateMyOvertimeRequest);
|
||||
router.delete('/portal/overtime-requests/:attendanceId', portalController.deleteMyOvertimeRequest);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-overtime-requests',
|
||||
@@ -113,6 +119,8 @@ router.post(
|
||||
|
||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||
router.put('/portal/purchase-requests/:id', portalController.updateMyPurchaseRequest);
|
||||
router.delete('/portal/purchase-requests/:id', portalController.deleteMyPurchaseRequest);
|
||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||
router.get('/portal/salaries', portalController.getMySalaries);
|
||||
|
||||
@@ -134,6 +142,22 @@ router.post(
|
||||
},
|
||||
portalController.submitExpenseClaim
|
||||
);
|
||||
router.put(
|
||||
'/portal/expense-claims/:id',
|
||||
(req, res, next) => {
|
||||
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || 'تعذر رفع المرفقات',
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
portalController.updateMyExpenseClaim
|
||||
);
|
||||
router.delete('/portal/expense-claims/:id', portalController.deleteMyExpenseClaim);
|
||||
router.get(
|
||||
'/portal/expense-claims/attachments/:attachmentId/view',
|
||||
portalController.viewExpenseClaimAttachment
|
||||
|
||||
@@ -295,7 +295,13 @@ export class PortalController {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status, search);
|
||||
const paid = req.query.paid as string | undefined;
|
||||
const data = await portalService.getManagedExpenseClaims(
|
||||
req.user?.employeeId,
|
||||
status,
|
||||
search,
|
||||
paid,
|
||||
);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -375,6 +381,178 @@ export class PortalController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PERSONAL EDIT/DELETE (pending only) ==========
|
||||
|
||||
async updateMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = { ...req.body };
|
||||
const leaveType = body.leaveType ? String(body.leaveType).toUpperCase() : undefined;
|
||||
|
||||
let startDate: Date | undefined;
|
||||
let endDate: Date | undefined;
|
||||
|
||||
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
|
||||
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
|
||||
endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
|
||||
} else if (body.startDate || body.endDate) {
|
||||
startDate = body.startDate ? new Date(body.startDate) : undefined;
|
||||
endDate = body.endDate ? new Date(body.endDate) : undefined;
|
||||
}
|
||||
|
||||
const result = await portalService.updateMyLeave(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
{
|
||||
leaveType,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: body.reason,
|
||||
},
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الإجازة'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.deleteMyLeave(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم حذف طلب الإجازة'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.updateMyPurchaseRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الشراء'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.deleteMyPurchaseRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم حذف طلب الشراء'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.updateMyLoan(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم تعديل طلب القرض'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.deleteMyLoan(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم حذف طلب القرض'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.updateMyOvertimeRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.attendanceId,
|
||||
{ hours: req.body.hours, reason: req.body.reason },
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الساعات الإضافية'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.deleteMyOvertimeRequest(
|
||||
req.user?.employeeId,
|
||||
req.params.attendanceId,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم حذف طلب الساعات الإضافية'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = { ...req.body };
|
||||
if (typeof body.items === 'string') {
|
||||
body.items = JSON.parse(body.items);
|
||||
}
|
||||
if (typeof body.removeAttachmentIds === 'string') {
|
||||
try {
|
||||
body.removeAttachmentIds = JSON.parse(body.removeAttachmentIds);
|
||||
} catch {
|
||||
body.removeAttachmentIds = [];
|
||||
}
|
||||
}
|
||||
const files = (req.files as Express.Multer.File[] | undefined) || [];
|
||||
const data = await portalService.updateMyExpenseClaim(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
body,
|
||||
req.user!.id,
|
||||
files
|
||||
);
|
||||
res.json(ResponseFormatter.success(data, 'تم تعديل كشف المصاريف'));
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('نوع الملف غير مدعوم')) {
|
||||
return res.status(400).json({ success: false, message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await portalService.deleteMyExpenseClaim(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(result, 'تم حذف كشف المصاريف'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const portalController = new PortalController();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { hrService } from './hr.service';
|
||||
import { notificationsService } from '../notifications/notifications.service';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
|
||||
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
|
||||
@@ -730,6 +731,7 @@ return claimWithAttachments;
|
||||
employeeId: string | undefined,
|
||||
status?: string,
|
||||
search?: string,
|
||||
paid?: string,
|
||||
) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
|
||||
@@ -739,6 +741,12 @@ return claimWithAttachments;
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (paid === 'paid') {
|
||||
where.isPaid = true;
|
||||
} else if (paid === 'unpaid') {
|
||||
where.isPaid = false;
|
||||
}
|
||||
|
||||
const trimmedSearch = search?.trim();
|
||||
if (trimmedSearch) {
|
||||
where.employee = {
|
||||
@@ -975,6 +983,376 @@ return claimWithAttachments;
|
||||
take: 24,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== PERSONAL PORTAL EDIT/DELETE (PENDING-only) ==========
|
||||
// These actions are restricted to the request owner and only while the
|
||||
// request is still in its initial pending state.
|
||||
|
||||
// ---------- Leaves ----------
|
||||
async updateMyLeave(
|
||||
employeeId: string | undefined,
|
||||
leaveId: string,
|
||||
data: { leaveType?: string; startDate?: Date; endDate?: Date; reason?: string },
|
||||
userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
|
||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
|
||||
if (leave.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||
}
|
||||
if (leave.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
// Delete and re-create through the normal validated path so we
|
||||
// benefit from leave-balance checks and audit logging.
|
||||
await prisma.leave.delete({ where: { id: leaveId } });
|
||||
|
||||
return hrService.createLeaveRequest(
|
||||
{
|
||||
employeeId: empId,
|
||||
leaveType: data.leaveType ?? leave.leaveType,
|
||||
startDate: data.startDate ?? leave.startDate,
|
||||
endDate: data.endDate ?? leave.endDate,
|
||||
reason: data.reason !== undefined ? data.reason : leave.reason || undefined,
|
||||
},
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async deleteMyLeave(employeeId: string | undefined, leaveId: string, _userId: string) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
|
||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
|
||||
if (leave.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||
}
|
||||
if (leave.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||
}
|
||||
await prisma.leave.delete({ where: { id: leaveId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------- Purchase requests ----------
|
||||
async updateMyPurchaseRequest(
|
||||
employeeId: string | undefined,
|
||||
requestId: string,
|
||||
data: { items?: any[]; reason?: string; priority?: string },
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
|
||||
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
const items = Array.isArray(data.items) ? data.items : (existing.items as any[]) || [];
|
||||
const totalAmount = items.reduce(
|
||||
(s: number, i: any) =>
|
||||
s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)),
|
||||
0
|
||||
);
|
||||
|
||||
return prisma.purchaseRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
items,
|
||||
totalAmount,
|
||||
reason: data.reason !== undefined ? data.reason : existing.reason,
|
||||
priority: data.priority ?? existing.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMyPurchaseRequest(
|
||||
employeeId: string | undefined,
|
||||
requestId: string,
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
|
||||
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||
}
|
||||
await prisma.purchaseRequest.delete({ where: { id: requestId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------- Loans ----------
|
||||
async updateMyLoan(
|
||||
employeeId: string | undefined,
|
||||
loanId: string,
|
||||
data: { type?: string; amount?: number; installments?: number; reason?: string },
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
|
||||
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING_HR') {
|
||||
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
const amount = data.amount !== undefined ? Number(data.amount) : Number(existing.amount);
|
||||
const installments =
|
||||
data.installments !== undefined ? Number(data.installments) : existing.installments;
|
||||
const monthlyAmount = installments > 0 ? amount / installments : amount;
|
||||
|
||||
return prisma.loan.update({
|
||||
where: { id: loanId },
|
||||
data: {
|
||||
type: data.type ?? existing.type,
|
||||
amount,
|
||||
installments,
|
||||
monthlyAmount,
|
||||
reason: data.reason !== undefined ? data.reason : existing.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMyLoan(employeeId: string | undefined, loanId: string, _userId: string) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
|
||||
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING_HR') {
|
||||
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||
}
|
||||
await prisma.loan.delete({ where: { id: loanId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------- Overtime requests (stored as attendance rows) ----------
|
||||
async updateMyOvertimeRequest(
|
||||
employeeId: string | undefined,
|
||||
attendanceId: string,
|
||||
data: { hours?: number; reason?: string },
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
|
||||
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||
if (att.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||
}
|
||||
const parsed = this.parseOvertimeRequestNote(att.notes);
|
||||
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||
if (parsed.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
const hours = data.hours !== undefined ? Number(data.hours) : parsed.hours;
|
||||
const reason = data.reason !== undefined ? data.reason : parsed.reason;
|
||||
|
||||
if (!hours || hours <= 0) throw new AppError(400, 'عدد الساعات غير صالح');
|
||||
if (!reason || !String(reason).trim()) throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
|
||||
|
||||
const updatedNote = this.buildOvertimeRequestNote(hours, String(reason).trim(), 'PENDING');
|
||||
const updated = await prisma.attendance.update({
|
||||
where: { id: attendanceId },
|
||||
data: { overtimeHours: hours, notes: updatedNote },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.formatOvertimeRequest(updated);
|
||||
}
|
||||
|
||||
async deleteMyOvertimeRequest(
|
||||
employeeId: string | undefined,
|
||||
attendanceId: string,
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
|
||||
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||
if (att.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||
}
|
||||
const parsed = this.parseOvertimeRequestNote(att.notes);
|
||||
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||
if (parsed.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||
}
|
||||
// Clear the overtime request markers but keep the attendance row intact.
|
||||
await prisma.attendance.update({
|
||||
where: { id: attendanceId },
|
||||
data: { overtimeHours: 0, notes: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------- Expense claims ----------
|
||||
async updateMyExpenseClaim(
|
||||
employeeId: string | undefined,
|
||||
claimId: string,
|
||||
data: {
|
||||
items?: Array<{
|
||||
expenseDate?: string;
|
||||
amount?: number | string;
|
||||
entityName?: string;
|
||||
description?: string;
|
||||
projectOrTender?: string;
|
||||
proofRef?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
removeAttachmentIds?: string[];
|
||||
},
|
||||
userId: string,
|
||||
newFiles?: Express.Multer.File[]
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
|
||||
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const normalizedItems = items
|
||||
.map((item) => ({
|
||||
expenseDate: item.expenseDate || '',
|
||||
amount: Number(item.amount || 0),
|
||||
entityName: item.entityName?.trim() || '',
|
||||
description: item.description?.trim() || '',
|
||||
projectOrTender: item.projectOrTender?.trim() || '',
|
||||
proofRef: item.proofRef?.trim() || '',
|
||||
}))
|
||||
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
|
||||
|
||||
if (normalizedItems.length === 0) {
|
||||
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
|
||||
}
|
||||
|
||||
const totalAmount = normalizedItems.reduce(
|
||||
(sum, item) => sum + Number(item.amount || 0),
|
||||
0
|
||||
);
|
||||
const firstItem = normalizedItems[0];
|
||||
|
||||
// Remove selected attachments (DB + file on disk).
|
||||
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
id: { in: data.removeAttachmentIds },
|
||||
entityType: 'EXPENSE_CLAIM',
|
||||
entityId: claimId,
|
||||
},
|
||||
});
|
||||
for (const a of attachments) {
|
||||
try {
|
||||
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
await prisma.attachment.deleteMany({
|
||||
where: { id: { in: data.removeAttachmentIds }, entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.expenseClaim.update({
|
||||
where: { id: claimId },
|
||||
data: {
|
||||
items: normalizedItems as any,
|
||||
totalAmount,
|
||||
expenseDate: new Date(firstItem.expenseDate),
|
||||
amount: totalAmount,
|
||||
description: data.description?.trim() || null,
|
||||
projectOrTender: firstItem.projectOrTender || null,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (newFiles && newFiles.length > 0) {
|
||||
await Promise.all(
|
||||
newFiles.map((file) =>
|
||||
prisma.attachment.create({
|
||||
data: {
|
||||
entityType: 'EXPENSE_CLAIM',
|
||||
entityId: claimId,
|
||||
fileName: path.basename(file.path),
|
||||
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [withFiles] = await this.attachExpenseClaimFiles([updated]);
|
||||
return withFiles;
|
||||
}
|
||||
|
||||
async deleteMyExpenseClaim(
|
||||
employeeId: string | undefined,
|
||||
claimId: string,
|
||||
_userId: string
|
||||
) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
|
||||
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||
if (existing.employeeId !== empId) {
|
||||
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||
}
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||
});
|
||||
for (const a of attachments) {
|
||||
try {
|
||||
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
await prisma.attachment.deleteMany({
|
||||
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||
});
|
||||
await prisma.expenseClaim.delete({ where: { id: claimId } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const portalService = new PortalService();
|
||||
@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||
// but multer/busboy decode them as latin1 by default. Reverse it so
|
||||
// Arabic filenames are stored intact in the DB.
|
||||
try {
|
||||
const decoded = Buffer.from(file.originalname || '', 'latin1').toString('utf8');
|
||||
file.originalname = decoded;
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
const extName = path.extname(file.originalname || '') || '';
|
||||
cb(null, `${crypto.randomUUID()}${extName}`);
|
||||
},
|
||||
});
|
||||
const upload = multer({
|
||||
@@ -93,6 +102,7 @@ router.post(
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
[
|
||||
body('tenderNumber').notEmpty().trim(),
|
||||
body('issueNumber').optional().trim(),
|
||||
body('issuingBodyName').notEmpty().trim(),
|
||||
body('title').notEmpty().trim(),
|
||||
body('termsValue').isNumeric(),
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface CreateTenderData {
|
||||
issuingBodyName: string;
|
||||
title: string;
|
||||
tenderNumber: string;
|
||||
issueNumber?: string;
|
||||
|
||||
termsValue: number;
|
||||
bondValue: number;
|
||||
@@ -353,6 +354,7 @@ private getEffectiveTenderStatus(tender: {
|
||||
const tender = await prisma.tender.create({
|
||||
data: {
|
||||
tenderNumber,
|
||||
issueNumber: data.issueNumber?.trim() || null,
|
||||
issuingBodyName: data.issuingBodyName.trim(),
|
||||
title: data.title.trim(),
|
||||
termsValue: data.termsValue,
|
||||
@@ -392,6 +394,7 @@ private getEffectiveTenderStatus(tender: {
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ title: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
@@ -496,6 +499,9 @@ private getEffectiveTenderStatus(tender: {
|
||||
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
||||
}
|
||||
if (data.title !== undefined) updateData.title = data.title.trim();
|
||||
if (data.issueNumber !== undefined) {
|
||||
updateData.issueNumber = data.issueNumber?.trim() || null;
|
||||
}
|
||||
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
||||
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
||||
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user