edit for portal & tender

This commit is contained in:
Aya
2026-06-03 13:01:51 +03:00
parent 61ca570e7a
commit 96386887fb
17 changed files with 1280 additions and 147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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