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) {
|
||||
|
||||
@@ -75,12 +75,22 @@ export default function PortalExpenseClaimsPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [removeAttachmentIds, setRemoveAttachmentIds] = useState<string[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<
|
||||
Array<{ id: string; originalName?: string; mimeType?: string }>
|
||||
>([]);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||
const filteredClaims = useMemo(() => {
|
||||
if (statusFilter === 'all') return claims;
|
||||
return claims.filter((claim) => claim.status === statusFilter);
|
||||
}, [claims, statusFilter]);
|
||||
return claims.filter((claim) => {
|
||||
if (statusFilter !== 'all' && claim.status !== statusFilter) return false;
|
||||
if (paidFilter === 'paid' && !claim.isPaid) return false;
|
||||
if (paidFilter === 'unpaid' && claim.isPaid) return false;
|
||||
return true;
|
||||
});
|
||||
}, [claims, statusFilter, paidFilter]);
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||
@@ -130,6 +140,51 @@ export default function PortalExpenseClaimsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(initialForm);
|
||||
setEditingId(null);
|
||||
setRemoveAttachmentIds([]);
|
||||
setExistingAttachments([]);
|
||||
};
|
||||
|
||||
const openEdit = (claim: ExpenseClaim) => {
|
||||
setEditingId(claim.id);
|
||||
const items = Array.isArray(claim.items) && claim.items.length > 0
|
||||
? claim.items.map((it: any) => ({
|
||||
expenseDate: it.expenseDate ? String(it.expenseDate).split('T')[0] : '',
|
||||
amount: String(it.amount ?? ''),
|
||||
entityName: it.entityName || '',
|
||||
description: it.description || '',
|
||||
projectOrTender: it.projectOrTender || '',
|
||||
proofRef: it.proofRef || '',
|
||||
}))
|
||||
: [emptyLine()];
|
||||
setForm({
|
||||
items,
|
||||
description: claim.description || '',
|
||||
attachments: [],
|
||||
});
|
||||
setExistingAttachments(
|
||||
(claim.attachments || []).map((a) => ({
|
||||
id: a.id,
|
||||
originalName: a.originalName,
|
||||
mimeType: a.mimeType,
|
||||
}))
|
||||
);
|
||||
setRemoveAttachmentIds([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف كشف المصاريف؟')) return;
|
||||
try {
|
||||
await portalAPI.deleteExpenseClaim(id);
|
||||
setClaims((prev) => prev.filter((c) => c.id !== id));
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || 'فشل الحذف');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
@@ -175,14 +230,22 @@ export default function PortalExpenseClaimsPage() {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (editingId) {
|
||||
await portalAPI.updateExpenseClaim(editingId, {
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
removeAttachmentIds,
|
||||
});
|
||||
} else {
|
||||
await portalAPI.submitExpenseClaim({
|
||||
items,
|
||||
description: form.description.trim() || undefined,
|
||||
attachments: form.attachments,
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
setForm(initialForm);
|
||||
resetForm();
|
||||
setShowModal(false);
|
||||
await loadClaims();
|
||||
} catch (err: any) {
|
||||
@@ -206,14 +269,14 @@ export default function PortalExpenseClaimsPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true); }}
|
||||
className="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
|
||||
>
|
||||
+ طلب كشف مصاريف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900">
|
||||
@@ -232,6 +295,16 @@ export default function PortalExpenseClaimsPage() {
|
||||
<option value="REJECTED">مرفوض</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={paidFilter}
|
||||
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">القبض: الكل</option>
|
||||
<option value="paid">مقبوض</option>
|
||||
<option value="unpaid">غير مقبوض</option>
|
||||
</select>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||
<div className="text-sm text-gray-500">آخر تحديث</div>
|
||||
<div className="mt-2 text-base font-semibold text-gray-900">
|
||||
@@ -279,6 +352,24 @@ export default function PortalExpenseClaimsPage() {
|
||||
|
||||
{getStatusLabel(claim.status)}
|
||||
</span>
|
||||
{claim.status === 'PENDING' && (
|
||||
<span className="inline-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(claim)}
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
تعديل
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(claim.id)}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
||||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
@@ -454,8 +545,8 @@ export default function PortalExpenseClaimsPage() {
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="كشف مصاريف جديد"
|
||||
onClose={() => { setShowModal(false); resetForm(); }}
|
||||
title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -587,6 +678,36 @@ export default function PortalExpenseClaimsPage() {
|
||||
المرفقات
|
||||
</label>
|
||||
|
||||
{editingId && existingAttachments.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
<div className="text-xs text-gray-500">المرفقات الحالية:</div>
|
||||
{existingAttachments.map((a) => {
|
||||
const isRemoved = removeAttachmentIds.includes(a.id);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
|
||||
isRemoved ? 'bg-red-50 border-red-200 text-red-600 line-through' : 'bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{a.originalName || a.id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRemoveAttachmentIds((prev) =>
|
||||
isRemoved ? prev.filter((id) => id !== a.id) : [...prev, a.id]
|
||||
);
|
||||
}}
|
||||
className="text-red-600 hover:underline shrink-0 ms-2"
|
||||
>
|
||||
{isRemoved ? 'استرجاع' : 'إزالة'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
@@ -647,7 +768,7 @@ export default function PortalExpenseClaimsPage() {
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
onClick={() => { setShowModal(false); resetForm(); }}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
إلغاء
|
||||
@@ -658,7 +779,7 @@ export default function PortalExpenseClaimsPage() {
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'جارٍ الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -32,9 +32,10 @@ const toCompanyDateTime = (date: string, time: string) => {
|
||||
}
|
||||
|
||||
const formatCompanyTime = (value: string) => {
|
||||
return new Date(value).toLocaleTimeString('en-US', {
|
||||
return new Date(value).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: COMPANY_TIME_ZONE,
|
||||
})
|
||||
}
|
||||
@@ -51,6 +52,7 @@ export default function PortalLeavePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
leaveType: 'ANNUAL',
|
||||
@@ -74,6 +76,67 @@ export default function PortalLeavePage() {
|
||||
}
|
||||
|
||||
useEffect(() => load(), [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: '',
|
||||
})
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (l: any) => {
|
||||
setEditingId(l.id)
|
||||
if (l.leaveType === 'HOURLY') {
|
||||
const start = new Date(l.startDate)
|
||||
const end = new Date(l.endDate)
|
||||
const dateStr = start.toLocaleDateString('en-CA', { timeZone: COMPANY_TIME_ZONE })
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: COMPANY_TIME_ZONE,
|
||||
})
|
||||
setForm({
|
||||
leaveType: 'HOURLY',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: dateStr,
|
||||
startTime: fmt(start),
|
||||
endTime: fmt(end),
|
||||
reason: l.reason || '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: String(l.startDate).split('T')[0],
|
||||
endDate: String(l.endDate).split('T')[0],
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: l.reason || '',
|
||||
})
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الإجازة؟')) return
|
||||
try {
|
||||
await portalAPI.deleteLeaveRequest(id)
|
||||
toast.success('تم حذف الطلب')
|
||||
load()
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -115,19 +178,15 @@ export default function PortalLeavePage() {
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
portalAPI.submitLeaveRequest(payload)
|
||||
const action = editingId
|
||||
? portalAPI.updateLeaveRequest(editingId, payload)
|
||||
: portalAPI.submitLeaveRequest(payload)
|
||||
|
||||
action
|
||||
.then(() => {
|
||||
setShowModal(false)
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: '',
|
||||
})
|
||||
toast.success('تم إرسال طلب الإجازة')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
|
||||
load()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -151,7 +210,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -202,9 +261,29 @@ export default function PortalLeavePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{l.status === 'PENDING' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(l)}
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
تعديل
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(l.id)}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -213,7 +292,11 @@ export default function PortalLeavePage() {
|
||||
</div>
|
||||
|
||||
{/* الفورم */}
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{/* نوع الإجازة */}
|
||||
@@ -315,7 +398,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
onClick={() => { setShowModal(false); resetForm() }}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -326,7 +409,7 @@ export default function PortalLeavePage() {
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (loan: Loan) => {
|
||||
setEditingId(loan.id)
|
||||
setForm({
|
||||
type: loan.type,
|
||||
amount: String(loan.amount ?? ''),
|
||||
installments: String(loan.installments ?? '1'),
|
||||
reason: loan.reason || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب القرض؟')) return
|
||||
try {
|
||||
await portalAPI.deleteLoanRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
setLoans((prev) => prev.filter((l) => l.id !== id))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const amount = parseFloat(form.amount)
|
||||
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
portalAPI.submitLoanRequest({
|
||||
const payload = {
|
||||
type: form.type,
|
||||
amount,
|
||||
installments: parseInt(form.installments) || 1,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
}
|
||||
const action = editingId
|
||||
? portalAPI.updateLoanRequest(editingId, payload)
|
||||
: portalAPI.submitLoanRequest(payload)
|
||||
action
|
||||
.then((loan) => {
|
||||
if (editingId) {
|
||||
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
|
||||
} else {
|
||||
setLoans((prev) => [loan, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
toast.success('تم إرسال طلب القرض')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{loan.status === 'PENDING_HR' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(loan)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(loan.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loan.rejectedReason && (
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
||||
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
||||
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState('PENDING');
|
||||
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||
const [payingId, setPayingId] = useState<string | null>(null);
|
||||
@@ -57,12 +58,17 @@ export default function ManagedExpenseClaimsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const claimId = searchParams.get('claimId');
|
||||
|
||||
async function loadClaims(status = statusFilter, search = searchQuery) {
|
||||
async function loadClaims(
|
||||
status = statusFilter,
|
||||
search = searchQuery,
|
||||
paid: 'all' | 'paid' | 'unpaid' = paidFilter,
|
||||
) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await portalAPI.getManagedExpenseClaims(
|
||||
status === 'all' ? undefined : status,
|
||||
search.trim() || undefined,
|
||||
paid,
|
||||
);
|
||||
setClaims(data);
|
||||
} catch (error: any) {
|
||||
@@ -75,11 +81,11 @@ export default function ManagedExpenseClaimsPage() {
|
||||
useEffect(() => {
|
||||
// Debounce the search so we don't fire a request on every keystroke.
|
||||
const handle = setTimeout(() => {
|
||||
loadClaims(statusFilter, searchQuery);
|
||||
loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, searchQuery]);
|
||||
}, [statusFilter, searchQuery, paidFilter]);
|
||||
|
||||
async function openAttachment(attachment: any) {
|
||||
try {
|
||||
@@ -108,7 +114,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
try {
|
||||
setSubmittingId(id);
|
||||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||||
await loadClaims(statusFilter, searchQuery);
|
||||
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||
} finally {
|
||||
@@ -141,7 +147,7 @@ export default function ManagedExpenseClaimsPage() {
|
||||
setRejectModalOpen(false);
|
||||
setSelectedClaim(null);
|
||||
setRejectReason('');
|
||||
await loadClaims(statusFilter, searchQuery);
|
||||
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
||||
} finally {
|
||||
@@ -229,6 +235,19 @@ export default function ManagedExpenseClaimsPage() {
|
||||
<option value="all">الكل</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">القبض:</label>
|
||||
<select
|
||||
value={paidFilter}
|
||||
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">الكل</option>
|
||||
<option value="paid">مقبوض</option>
|
||||
<option value="unpaid">غير مقبوض</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
date: '',
|
||||
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ date: '', hours: '', reason: '' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (item: PortalOvertimeRequest) => {
|
||||
setEditingId(item.attendanceId || item.id)
|
||||
setForm({
|
||||
date: String(item.date).split('T')[0],
|
||||
hours: String(item.hours ?? ''),
|
||||
reason: item.reason || '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الساعات الإضافية؟')) return
|
||||
try {
|
||||
await portalAPI.deleteOvertimeRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
if (editingId) {
|
||||
await portalAPI.updateOvertimeRequest(editingId, {
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم تعديل الطلب')
|
||||
} else {
|
||||
await portalAPI.submitOvertimeRequest({
|
||||
date: form.date,
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم إرسال الطلب')
|
||||
}
|
||||
setOpen(false)
|
||||
setForm({ date: '', hours: '', reason: '' })
|
||||
resetForm()
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => { resetForm(); setOpen(true) }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
{item.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(item)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(item.attendanceId || item.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => { setOpen(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
disabled={!!editingId}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => { setOpen(false); resetForm() }}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
||||
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||
reason: '',
|
||||
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
|
||||
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
||||
}))
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const openEdit = (pr: PurchaseRequest) => {
|
||||
setEditingId(pr.id)
|
||||
const items = Array.isArray(pr.items) && pr.items.length > 0
|
||||
? pr.items.map((it: any) => ({
|
||||
description: String(it.description || ''),
|
||||
quantity: Number(it.quantity || 1),
|
||||
estimatedPrice: String(it.estimatedPrice ?? ''),
|
||||
}))
|
||||
: [{ description: '', quantity: 1, estimatedPrice: '' }]
|
||||
setForm({ items, reason: pr.reason || '', priority: pr.priority || 'NORMAL' })
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('حذف طلب الشراء؟')) return
|
||||
try {
|
||||
await portalAPI.deletePurchaseRequest(id)
|
||||
toast.success('تم الحذف')
|
||||
setRequests((prev) => prev.filter((r) => r.id !== id))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const items = form.items
|
||||
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
portalAPI.submitPurchaseRequest({
|
||||
items,
|
||||
reason: form.reason || undefined,
|
||||
priority: form.priority,
|
||||
})
|
||||
const payload = { items, reason: form.reason || undefined, priority: form.priority }
|
||||
const action = editingId
|
||||
? portalAPI.updatePurchaseRequest(editingId, payload)
|
||||
: portalAPI.submitPurchaseRequest(payload)
|
||||
action
|
||||
.then((pr) => {
|
||||
if (editingId) {
|
||||
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
|
||||
} else {
|
||||
setRequests((prev) => [pr, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
toast.success('تم إرسال طلب الشراء')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={() => { resetForm(); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -121,9 +155,17 @@ export default function PortalPurchaseRequestsPage() {
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{pr.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openEdit(pr)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||
<button type="button" onClick={() => handleDelete(pr.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -87,10 +87,13 @@ function TenderDetailContent() {
|
||||
const [completeNotes, setCompleteNotes] = useState('')
|
||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
||||
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
|
||||
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
|
||||
const termsInputRef = useRef<HTMLInputElement>(null)
|
||||
const costInputRef = useRef<HTMLInputElement>(null)
|
||||
const offersInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchTender = async () => {
|
||||
try {
|
||||
@@ -213,11 +216,15 @@ function TenderDetailContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleTenderFileUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
category?: string,
|
||||
) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
setSubmitting(true)
|
||||
if (category) setUploadingCategory(category)
|
||||
else setSubmitting(true)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
@@ -225,7 +232,7 @@ function TenderDetailContent() {
|
||||
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||
for (const file of files) {
|
||||
try {
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
|
||||
successCount++
|
||||
} catch (err: any) {
|
||||
failCount++
|
||||
@@ -244,6 +251,7 @@ function TenderDetailContent() {
|
||||
if (successCount > 0) fetchTender()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setUploadingCategory(null)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
@@ -525,20 +533,50 @@ function TenderDetailContent() {
|
||||
|
||||
{activeTab === 'attachments' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{(() => {
|
||||
const all = (tender.attachments || []) as any[]
|
||||
const sections: Array<{
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
}> = [
|
||||
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
|
||||
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
|
||||
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
|
||||
]
|
||||
|
||||
// Legacy attachments without a recognized category live under
|
||||
// the dafter section by default so nothing gets hidden.
|
||||
const knownCategories = new Set(sections.map((s) => s.category))
|
||||
const inSection = (a: any, category: string) =>
|
||||
a.category === category ||
|
||||
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => {
|
||||
const items = all.filter((a) => inSection(a, section.category))
|
||||
const isUploading = uploadingCategory === section.category
|
||||
return (
|
||||
<div key={section.key} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
ref={section.ref}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleTenderFileUpload}
|
||||
onChange={(e) => handleTenderFileUpload(e, section.category)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => section.ref.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{submitting ? (
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
@@ -546,12 +584,13 @@ function TenderDetailContent() {
|
||||
{t('tenders.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!tender.attachments?.length ? (
|
||||
<p className="text-gray-500">{t('common.noData')}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tender.attachments.map((a: any) => (
|
||||
{items.map((a: any) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between border rounded px-3 py-2"
|
||||
@@ -565,7 +604,6 @@ function TenderDetailContent() {
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{getDisplayFileName(a)}
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('حذف الملف؟')) return
|
||||
@@ -586,6 +624,12 @@ function TenderDetailContent() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
|
||||
@@ -58,6 +58,7 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
||||
|
||||
const getInitialFormData = (): CreateTenderData => ({
|
||||
tenderNumber: '',
|
||||
issueNumber: '',
|
||||
issuingBodyName: '',
|
||||
title: '',
|
||||
termsValue: 0,
|
||||
@@ -114,6 +115,7 @@ function TendersContent() {
|
||||
|
||||
const fillFormFromTender = (tender: Tender): CreateTenderData => ({
|
||||
tenderNumber: tender.tenderNumber || '',
|
||||
issueNumber: tender.issueNumber || '',
|
||||
issuingBodyName: tender.issuingBodyName || '',
|
||||
title: tender.title || '',
|
||||
termsValue: Number(tender.termsValue || 0),
|
||||
@@ -316,6 +318,19 @@ function TendersContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
رقم العدد
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.issueNumber || ''}
|
||||
onChange={(e) => setFormData({ ...formData, issueNumber: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="رقم العدد (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('tenders.titleLabel')} *
|
||||
@@ -684,6 +699,9 @@ function TendersContent() {
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
{t('tenders.tenderNumber') || 'Number'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
رقم العدد
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
{t('tenders.title') || 'Title'}
|
||||
</th>
|
||||
@@ -708,6 +726,9 @@ function TendersContent() {
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900 text-right align-middle">
|
||||
{tender.tenderNumber}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 text-right align-middle">
|
||||
{tender.issueNumber || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 text-right align-middle">
|
||||
{tender.title}
|
||||
</td>
|
||||
|
||||
@@ -234,6 +234,18 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateLoanRequest: async (
|
||||
id: string,
|
||||
data: { type?: string; amount?: number; installments?: number; reason?: string }
|
||||
): Promise<Loan> => {
|
||||
const response = await api.put(`/hr/portal/loans/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteLoanRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/loans/${id}`)
|
||||
},
|
||||
|
||||
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||
const response = await api.get('/hr/portal/overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -248,6 +260,18 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateOvertimeRequest: async (
|
||||
attendanceId: string,
|
||||
data: { hours?: number; reason?: string }
|
||||
): Promise<PortalOvertimeRequest> => {
|
||||
const response = await api.put(`/hr/portal/overtime-requests/${attendanceId}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteOvertimeRequest: async (attendanceId: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/overtime-requests/${attendanceId}`)
|
||||
},
|
||||
|
||||
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -292,6 +316,26 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateLeaveRequest: async (
|
||||
id: string,
|
||||
data: {
|
||||
leaveType?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
leaveDate?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
reason?: string
|
||||
}
|
||||
): Promise<Leave> => {
|
||||
const response = await api.put(`/hr/portal/leaves/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteLeaveRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/leaves/${id}`)
|
||||
},
|
||||
|
||||
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
|
||||
const response = await api.get('/hr/portal/purchase-requests')
|
||||
return response.data.data || []
|
||||
@@ -302,6 +346,22 @@ export const portalAPI = {
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updatePurchaseRequest: async (
|
||||
id: string,
|
||||
data: {
|
||||
items?: Array<{ description: string; quantity?: number; estimatedPrice?: number }>
|
||||
reason?: string
|
||||
priority?: string
|
||||
}
|
||||
): Promise<PurchaseRequest> => {
|
||||
const response = await api.put(`/hr/portal/purchase-requests/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deletePurchaseRequest: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/purchase-requests/${id}`)
|
||||
},
|
||||
|
||||
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
|
||||
const response = await api.get('/hr/portal/expense-claims')
|
||||
return response.data.data || []
|
||||
@@ -340,10 +400,50 @@ export const portalAPI = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getManagedExpenseClaims: async (status?: string, search?: string): Promise<ExpenseClaim[]> => {
|
||||
updateExpenseClaim: async (
|
||||
id: string,
|
||||
data: {
|
||||
items: Array<{
|
||||
expenseDate: string;
|
||||
amount: number;
|
||||
entityName?: string;
|
||||
description: string;
|
||||
projectOrTender?: string;
|
||||
proofRef?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
attachments?: File[];
|
||||
removeAttachmentIds?: string[];
|
||||
}
|
||||
): Promise<ExpenseClaim> => {
|
||||
const formData = new FormData();
|
||||
formData.append('items', JSON.stringify(data.items));
|
||||
if (data.description) formData.append('description', data.description);
|
||||
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
|
||||
formData.append('removeAttachmentIds', JSON.stringify(data.removeAttachmentIds));
|
||||
}
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
for (const file of data.attachments) formData.append('attachments', file);
|
||||
}
|
||||
const response = await api.put(`/hr/portal/expense-claims/${id}`, formData, {
|
||||
headers: { 'Content-Type': undefined as any },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
deleteExpenseClaim: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/portal/expense-claims/${id}`);
|
||||
},
|
||||
|
||||
getManagedExpenseClaims: async (
|
||||
status?: string,
|
||||
search?: string,
|
||||
paid?: 'all' | 'paid' | 'unpaid',
|
||||
): Promise<ExpenseClaim[]> => {
|
||||
const q = new URLSearchParams()
|
||||
if (status && status !== 'all') q.append('status', status)
|
||||
if (search && search.trim()) q.append('search', search.trim())
|
||||
if (paid && paid !== 'all') q.append('paid', paid)
|
||||
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api'
|
||||
export interface Tender {
|
||||
id: string
|
||||
tenderNumber: string
|
||||
issueNumber?: string | null
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
@@ -57,6 +58,7 @@ export interface TenderDirective {
|
||||
|
||||
export interface CreateTenderData {
|
||||
tenderNumber: string
|
||||
issueNumber?: string
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user