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 {
|
model Tender {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenderNumber String @unique
|
tenderNumber String @unique
|
||||||
|
issueNumber String?
|
||||||
issuingBodyName String
|
issuingBodyName String
|
||||||
title String
|
title String
|
||||||
termsValue Decimal @db.Decimal(15, 2)
|
termsValue Decimal @db.Decimal(15, 2)
|
||||||
@@ -943,6 +944,7 @@ model Tender {
|
|||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
convertedDeal Deal?
|
convertedDeal Deal?
|
||||||
@@index([tenderNumber])
|
@@index([tenderNumber])
|
||||||
|
@@index([issueNumber])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdById])
|
@@index([createdById])
|
||||||
@@index([announcementDate])
|
@@index([announcementDate])
|
||||||
|
|||||||
@@ -68,9 +68,13 @@ router.use(authenticate);
|
|||||||
router.get('/portal/me', portalController.getMe);
|
router.get('/portal/me', portalController.getMe);
|
||||||
router.get('/portal/loans', portalController.getMyLoans);
|
router.get('/portal/loans', portalController.getMyLoans);
|
||||||
router.post('/portal/loans', portalController.submitLoanRequest);
|
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/leave-balance', portalController.getMyLeaveBalance);
|
||||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||||
|
router.put('/portal/leaves/:id', portalController.updateMyLeave);
|
||||||
|
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/portal/managed-leaves',
|
'/portal/managed-leaves',
|
||||||
@@ -92,6 +96,8 @@ router.post(
|
|||||||
|
|
||||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||||
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
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(
|
router.get(
|
||||||
'/portal/managed-overtime-requests',
|
'/portal/managed-overtime-requests',
|
||||||
@@ -113,6 +119,8 @@ router.post(
|
|||||||
|
|
||||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
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/attendance', portalController.getMyAttendance);
|
||||||
router.get('/portal/salaries', portalController.getMySalaries);
|
router.get('/portal/salaries', portalController.getMySalaries);
|
||||||
|
|
||||||
@@ -134,6 +142,22 @@ router.post(
|
|||||||
},
|
},
|
||||||
portalController.submitExpenseClaim
|
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(
|
router.get(
|
||||||
'/portal/expense-claims/attachments/:attachmentId/view',
|
'/portal/expense-claims/attachments/:attachmentId/view',
|
||||||
portalController.viewExpenseClaimAttachment
|
portalController.viewExpenseClaimAttachment
|
||||||
|
|||||||
@@ -295,7 +295,13 @@ export class PortalController {
|
|||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined;
|
const status = req.query.status as string | undefined;
|
||||||
const search = req.query.search 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));
|
res.json(ResponseFormatter.success(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -375,6 +381,178 @@ export class PortalController {
|
|||||||
next(error);
|
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();
|
export const portalController = new PortalController();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AppError } from '../../shared/middleware/errorHandler';
|
|||||||
import { hrService } from './hr.service';
|
import { hrService } from './hr.service';
|
||||||
import { notificationsService } from '../notifications/notifications.service';
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
|
// 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
|
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
|
||||||
@@ -730,6 +731,7 @@ return claimWithAttachments;
|
|||||||
employeeId: string | undefined,
|
employeeId: string | undefined,
|
||||||
status?: string,
|
status?: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
|
paid?: string,
|
||||||
) {
|
) {
|
||||||
this.requireEmployeeId(employeeId);
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
@@ -739,6 +741,12 @@ return claimWithAttachments;
|
|||||||
where.status = status;
|
where.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paid === 'paid') {
|
||||||
|
where.isPaid = true;
|
||||||
|
} else if (paid === 'unpaid') {
|
||||||
|
where.isPaid = false;
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedSearch = search?.trim();
|
const trimmedSearch = search?.trim();
|
||||||
if (trimmedSearch) {
|
if (trimmedSearch) {
|
||||||
where.employee = {
|
where.employee = {
|
||||||
@@ -975,6 +983,376 @@ return claimWithAttachments;
|
|||||||
take: 24,
|
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();
|
export const portalService = new PortalService();
|
||||||
@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
// 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({
|
const upload = multer({
|
||||||
@@ -93,6 +102,7 @@ router.post(
|
|||||||
authorize('tenders', 'tenders', 'create'),
|
authorize('tenders', 'tenders', 'create'),
|
||||||
[
|
[
|
||||||
body('tenderNumber').notEmpty().trim(),
|
body('tenderNumber').notEmpty().trim(),
|
||||||
|
body('issueNumber').optional().trim(),
|
||||||
body('issuingBodyName').notEmpty().trim(),
|
body('issuingBodyName').notEmpty().trim(),
|
||||||
body('title').notEmpty().trim(),
|
body('title').notEmpty().trim(),
|
||||||
body('termsValue').isNumeric(),
|
body('termsValue').isNumeric(),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface CreateTenderData {
|
|||||||
issuingBodyName: string;
|
issuingBodyName: string;
|
||||||
title: string;
|
title: string;
|
||||||
tenderNumber: string;
|
tenderNumber: string;
|
||||||
|
issueNumber?: string;
|
||||||
|
|
||||||
termsValue: number;
|
termsValue: number;
|
||||||
bondValue: number;
|
bondValue: number;
|
||||||
@@ -353,6 +354,7 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
const tender = await prisma.tender.create({
|
const tender = await prisma.tender.create({
|
||||||
data: {
|
data: {
|
||||||
tenderNumber,
|
tenderNumber,
|
||||||
|
issueNumber: data.issueNumber?.trim() || null,
|
||||||
issuingBodyName: data.issuingBodyName.trim(),
|
issuingBodyName: data.issuingBodyName.trim(),
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
termsValue: data.termsValue,
|
termsValue: data.termsValue,
|
||||||
@@ -392,6 +394,7 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ title: { contains: filters.search, mode: 'insensitive' } },
|
{ title: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
||||||
];
|
];
|
||||||
@@ -496,6 +499,9 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
||||||
}
|
}
|
||||||
if (data.title !== undefined) updateData.title = data.title.trim();
|
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.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
||||||
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
||||||
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
||||||
|
|||||||
@@ -75,12 +75,22 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
|
||||||
|
const [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 [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||||
const filteredClaims = useMemo(() => {
|
const filteredClaims = useMemo(() => {
|
||||||
if (statusFilter === 'all') return claims;
|
return claims.filter((claim) => {
|
||||||
return claims.filter((claim) => claim.status === statusFilter);
|
if (statusFilter !== 'all' && claim.status !== statusFilter) return false;
|
||||||
}, [claims, statusFilter]);
|
if (paidFilter === 'paid' && !claim.isPaid) return false;
|
||||||
|
if (paidFilter === 'unpaid' && claim.isPaid) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [claims, statusFilter, paidFilter]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
@@ -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) {
|
async function openAttachment(attachment: any) {
|
||||||
try {
|
try {
|
||||||
@@ -175,14 +230,22 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
await portalAPI.submitExpenseClaim({
|
if (editingId) {
|
||||||
items,
|
await portalAPI.updateExpenseClaim(editingId, {
|
||||||
description: form.description.trim() || undefined,
|
items,
|
||||||
attachments: form.attachments,
|
description: form.description.trim() || undefined,
|
||||||
|
attachments: form.attachments,
|
||||||
|
removeAttachmentIds,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await portalAPI.submitExpenseClaim({
|
||||||
|
items,
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
attachments: form.attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
});
|
resetForm();
|
||||||
|
|
||||||
setForm(initialForm);
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await loadClaims();
|
await loadClaims();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -206,14 +269,14 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
</div>
|
</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="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
|
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
|
||||||
<div className="mt-2 text-2xl font-bold text-gray-900">
|
<div className="mt-2 text-2xl font-bold text-gray-900">
|
||||||
@@ -232,6 +295,16 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
<option value="REJECTED">مرفوض</option>
|
<option value="REJECTED">مرفوض</option>
|
||||||
</select>
|
</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="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
<div className="text-sm text-gray-500">آخر تحديث</div>
|
<div className="text-sm text-gray-500">آخر تحديث</div>
|
||||||
<div className="mt-2 text-base font-semibold text-gray-900">
|
<div className="mt-2 text-base font-semibold text-gray-900">
|
||||||
@@ -279,6 +352,24 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
|
|
||||||
{getStatusLabel(claim.status)}
|
{getStatusLabel(claim.status)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
{claim.status === 'APPROVED' && claim.approvalNote ? (
|
||||||
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
|
<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
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => { setShowModal(false); resetForm(); }}
|
||||||
title="كشف مصاريف جديد"
|
title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -587,6 +678,36 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
المرفقات
|
المرفقات
|
||||||
</label>
|
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
@@ -647,7 +768,7 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ const toCompanyDateTime = (date: string, time: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatCompanyTime = (value: string) => {
|
const formatCompanyTime = (value: string) => {
|
||||||
return new Date(value).toLocaleTimeString('en-US', {
|
return new Date(value).toLocaleTimeString('en-GB', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
timeZone: COMPANY_TIME_ZONE,
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ export default function PortalLeavePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
leaveType: 'ANNUAL',
|
leaveType: 'ANNUAL',
|
||||||
@@ -74,6 +76,67 @@ export default function PortalLeavePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => load(), [])
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -115,19 +178,15 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
|
||||||
portalAPI.submitLeaveRequest(payload)
|
const action = editingId
|
||||||
|
? portalAPI.updateLeaveRequest(editingId, payload)
|
||||||
|
: portalAPI.submitLeaveRequest(payload)
|
||||||
|
|
||||||
|
action
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({
|
resetForm()
|
||||||
leaveType: 'ANNUAL',
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
leaveDate: '',
|
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
reason: '',
|
|
||||||
})
|
|
||||||
toast.success('تم إرسال طلب الإجازة')
|
|
||||||
load()
|
load()
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
@@ -151,7 +210,7 @@ export default function PortalLeavePage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||||
<button
|
<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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -202,9 +261,29 @@ export default function PortalLeavePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
<div className="flex items-center gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -213,7 +292,11 @@ export default function PortalLeavePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* الفورم */}
|
{/* الفورم */}
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|
||||||
{/* نوع الإجازة */}
|
{/* نوع الإجازة */}
|
||||||
@@ -315,7 +398,7 @@ export default function PortalLeavePage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => { setShowModal(false); resetForm() }}
|
||||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||||
>
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
@@ -326,7 +409,7 @@ export default function PortalLeavePage() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = 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: '' })
|
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
|
|||||||
.finally(() => setLoading(false))
|
.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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const amount = parseFloat(form.amount)
|
const amount = parseFloat(form.amount)
|
||||||
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitLoanRequest({
|
const payload = {
|
||||||
type: form.type,
|
type: form.type,
|
||||||
amount,
|
amount,
|
||||||
installments: parseInt(form.installments) || 1,
|
installments: parseInt(form.installments) || 1,
|
||||||
reason: form.reason.trim(),
|
reason: form.reason.trim(),
|
||||||
})
|
}
|
||||||
|
const action = editingId
|
||||||
|
? portalAPI.updateLoanRequest(editingId, payload)
|
||||||
|
: portalAPI.submitLoanRequest(payload)
|
||||||
|
action
|
||||||
.then((loan) => {
|
.then((loan) => {
|
||||||
setLoans((prev) => [loan, ...prev])
|
if (editingId) {
|
||||||
|
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
|
||||||
|
} else {
|
||||||
|
setLoans((prev) => [loan, ...prev])
|
||||||
|
}
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
resetForm()
|
||||||
toast.success('تم إرسال طلب القرض')
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||||
.finally(() => setSubmitting(false))
|
.finally(() => setSubmitting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
||||||
<button
|
<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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
{loan.rejectedReason && (
|
{loan.rejectedReason && (
|
||||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
||||||
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
||||||
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
|
|||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState('PENDING');
|
const [statusFilter, setStatusFilter] = useState('PENDING');
|
||||||
|
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
const [payingId, setPayingId] = useState<string | null>(null);
|
const [payingId, setPayingId] = useState<string | null>(null);
|
||||||
@@ -57,12 +58,17 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const claimId = searchParams.get('claimId');
|
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await portalAPI.getManagedExpenseClaims(
|
const data = await portalAPI.getManagedExpenseClaims(
|
||||||
status === 'all' ? undefined : status,
|
status === 'all' ? undefined : status,
|
||||||
search.trim() || undefined,
|
search.trim() || undefined,
|
||||||
|
paid,
|
||||||
);
|
);
|
||||||
setClaims(data);
|
setClaims(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -75,11 +81,11 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Debounce the search so we don't fire a request on every keystroke.
|
// Debounce the search so we don't fire a request on every keystroke.
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
loadClaims(statusFilter, searchQuery);
|
loadClaims(statusFilter, searchQuery, paidFilter);
|
||||||
}, 400);
|
}, 400);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statusFilter, searchQuery]);
|
}, [statusFilter, searchQuery, paidFilter]);
|
||||||
|
|
||||||
async function openAttachment(attachment: any) {
|
async function openAttachment(attachment: any) {
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +114,7 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
try {
|
try {
|
||||||
setSubmittingId(id);
|
setSubmittingId(id);
|
||||||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||||||
await loadClaims(statusFilter, searchQuery);
|
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,7 +147,7 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
setRejectModalOpen(false);
|
setRejectModalOpen(false);
|
||||||
setSelectedClaim(null);
|
setSelectedClaim(null);
|
||||||
setRejectReason('');
|
setRejectReason('');
|
||||||
await loadClaims(statusFilter, searchQuery);
|
await loadClaims(statusFilter, searchQuery, paidFilter);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -229,6 +235,19 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
<option value="all">الكل</option>
|
<option value="all">الكل</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
date: '',
|
date: '',
|
||||||
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
|
|||||||
loadData()
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
await portalAPI.submitOvertimeRequest({
|
if (editingId) {
|
||||||
date: form.date,
|
await portalAPI.updateOvertimeRequest(editingId, {
|
||||||
hours,
|
hours,
|
||||||
reason: form.reason.trim(),
|
reason: form.reason.trim(),
|
||||||
})
|
})
|
||||||
toast.success('تم إرسال الطلب')
|
toast.success('تم تعديل الطلب')
|
||||||
|
} else {
|
||||||
|
await portalAPI.submitOvertimeRequest({
|
||||||
|
date: form.date,
|
||||||
|
hours,
|
||||||
|
reason: form.reason.trim(),
|
||||||
|
})
|
||||||
|
toast.success('تم إرسال الطلب')
|
||||||
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setForm({ date: '', hours: '', reason: '' })
|
resetForm()
|
||||||
loadData()
|
loadData()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||||
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{meta.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => { setOpen(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||||
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
|
|||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
disabled={!!editingId}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
|
|||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => { setOpen(false); resetForm() }}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||||
>
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||||
reason: '',
|
reason: '',
|
||||||
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const items = form.items
|
const items = form.items
|
||||||
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitPurchaseRequest({
|
const payload = { items, reason: form.reason || undefined, priority: form.priority }
|
||||||
items,
|
const action = editingId
|
||||||
reason: form.reason || undefined,
|
? portalAPI.updatePurchaseRequest(editingId, payload)
|
||||||
priority: form.priority,
|
: portalAPI.submitPurchaseRequest(payload)
|
||||||
})
|
action
|
||||||
.then((pr) => {
|
.then((pr) => {
|
||||||
setRequests((prev) => [pr, ...prev])
|
if (editingId) {
|
||||||
|
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
|
||||||
|
} else {
|
||||||
|
setRequests((prev) => [pr, ...prev])
|
||||||
|
}
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
resetForm()
|
||||||
toast.success('تم إرسال طلب الشراء')
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||||
.finally(() => setSubmitting(false))
|
.finally(() => setSubmitting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||||
<button
|
<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"
|
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" />
|
<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>
|
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||||
</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
</div>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -87,10 +87,13 @@ function TenderDetailContent() {
|
|||||||
const [completeNotes, setCompleteNotes] = useState('')
|
const [completeNotes, setCompleteNotes] = useState('')
|
||||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
||||||
const [directiveIdForUpload, setDirectiveIdForUpload] = 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 () => {
|
const fetchTender = async () => {
|
||||||
try {
|
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 || [])
|
const files = Array.from(e.target.files || [])
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
|
|
||||||
setSubmitting(true)
|
if (category) setUploadingCategory(category)
|
||||||
|
else setSubmitting(true)
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
|
|
||||||
@@ -225,7 +232,7 @@ function TenderDetailContent() {
|
|||||||
// Upload files sequentially so a failure of one file doesn't break the rest.
|
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
|
||||||
successCount++
|
successCount++
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
failCount++
|
failCount++
|
||||||
@@ -244,6 +251,7 @@ function TenderDetailContent() {
|
|||||||
if (successCount > 0) fetchTender()
|
if (successCount > 0) fetchTender()
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
|
setUploadingCategory(null)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,66 +533,102 @@ function TenderDetailContent() {
|
|||||||
|
|
||||||
{activeTab === 'attachments' && (
|
{activeTab === 'attachments' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
{(() => {
|
||||||
<input
|
const all = (tender.attachments || []) as any[]
|
||||||
type="file"
|
const sections: Array<{
|
||||||
ref={fileInputRef}
|
key: string
|
||||||
multiple
|
label: string
|
||||||
className="hidden"
|
category: string
|
||||||
onChange={handleTenderFileUpload}
|
ref: React.RefObject<HTMLInputElement>
|
||||||
/>
|
}> = [
|
||||||
<button
|
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
|
||||||
onClick={() => fileInputRef.current?.click()}
|
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
|
||||||
disabled={submitting}
|
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
]
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t('tenders.uploadFile')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!tender.attachments?.length ? (
|
// Legacy attachments without a recognized category live under
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
// the dafter section by default so nothing gets hidden.
|
||||||
) : (
|
const knownCategories = new Set(sections.map((s) => s.category))
|
||||||
<ul className="space-y-2">
|
const inSection = (a: any, category: string) =>
|
||||||
{tender.attachments.map((a: any) => (
|
a.category === category ||
|
||||||
<li
|
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
|
||||||
key={a.id}
|
|
||||||
className="flex items-center justify-between border rounded px-3 py-2"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
{getDisplayFileName(a)}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
return (
|
||||||
onClick={async () => {
|
<div className="space-y-6">
|
||||||
if (!confirm('حذف الملف؟')) return
|
{sections.map((section) => {
|
||||||
try {
|
const items = all.filter((a) => inSection(a, section.category))
|
||||||
await tendersAPI.deleteAttachment(a.id)
|
const isUploading = uploadingCategory === section.category
|
||||||
toast.success('تم الحذف')
|
return (
|
||||||
fetchTender()
|
<div key={section.key} className="border rounded-lg p-4">
|
||||||
} catch {
|
<div className="flex items-center justify-between mb-3">
|
||||||
toast.error('فشل الحذف')
|
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
|
||||||
}
|
<div>
|
||||||
}}
|
<input
|
||||||
className="text-red-600 text-sm hover:underline"
|
type="file"
|
||||||
>
|
ref={section.ref}
|
||||||
حذف
|
multiple
|
||||||
</button>
|
className="hidden"
|
||||||
</li>
|
onChange={(e) => handleTenderFileUpload(e, section.category)}
|
||||||
))}
|
/>
|
||||||
</ul>
|
<button
|
||||||
)}
|
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"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('tenders.uploadFile')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((a: any) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between border rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{getDisplayFileName(a)}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('حذف الملف؟')) return
|
||||||
|
try {
|
||||||
|
await tendersAPI.deleteAttachment(a.id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
fetchTender()
|
||||||
|
} catch {
|
||||||
|
toast.error('فشل الحذف')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const getInitialFormData = (): CreateTenderData => ({
|
const getInitialFormData = (): CreateTenderData => ({
|
||||||
tenderNumber: '',
|
tenderNumber: '',
|
||||||
|
issueNumber: '',
|
||||||
issuingBodyName: '',
|
issuingBodyName: '',
|
||||||
title: '',
|
title: '',
|
||||||
termsValue: 0,
|
termsValue: 0,
|
||||||
@@ -114,6 +115,7 @@ function TendersContent() {
|
|||||||
|
|
||||||
const fillFormFromTender = (tender: Tender): CreateTenderData => ({
|
const fillFormFromTender = (tender: Tender): CreateTenderData => ({
|
||||||
tenderNumber: tender.tenderNumber || '',
|
tenderNumber: tender.tenderNumber || '',
|
||||||
|
issueNumber: tender.issueNumber || '',
|
||||||
issuingBodyName: tender.issuingBodyName || '',
|
issuingBodyName: tender.issuingBodyName || '',
|
||||||
title: tender.title || '',
|
title: tender.title || '',
|
||||||
termsValue: Number(tender.termsValue || 0),
|
termsValue: Number(tender.termsValue || 0),
|
||||||
@@ -316,6 +318,19 @@ function TendersContent() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t('tenders.titleLabel')} *
|
{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">
|
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
{t('tenders.tenderNumber') || 'Number'}
|
{t('tenders.tenderNumber') || 'Number'}
|
||||||
</th>
|
</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">
|
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
{t('tenders.title') || 'Title'}
|
{t('tenders.title') || 'Title'}
|
||||||
</th>
|
</th>
|
||||||
@@ -708,6 +726,9 @@ function TendersContent() {
|
|||||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900 text-right align-middle">
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900 text-right align-middle">
|
||||||
{tender.tenderNumber}
|
{tender.tenderNumber}
|
||||||
</td>
|
</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">
|
<td className="px-6 py-4 text-sm text-gray-900 text-right align-middle">
|
||||||
{tender.title}
|
{tender.title}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
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[]> => {
|
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/overtime-requests')
|
const response = await api.get('/hr/portal/overtime-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -248,6 +260,18 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
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[]> => {
|
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/managed-overtime-requests')
|
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -292,6 +316,26 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
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[]> => {
|
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/purchase-requests')
|
const response = await api.get('/hr/portal/purchase-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -302,6 +346,22 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
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[]> => {
|
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
|
||||||
const response = await api.get('/hr/portal/expense-claims')
|
const response = await api.get('/hr/portal/expense-claims')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -340,10 +400,50 @@ export const portalAPI = {
|
|||||||
return response.data.data;
|
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()
|
const q = new URLSearchParams()
|
||||||
if (status && status !== 'all') q.append('status', status)
|
if (status && status !== 'all') q.append('status', status)
|
||||||
if (search && search.trim()) q.append('search', search.trim())
|
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()}`)
|
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../api'
|
|||||||
export interface Tender {
|
export interface Tender {
|
||||||
id: string
|
id: string
|
||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
|
issueNumber?: string | null
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export interface TenderDirective {
|
|||||||
|
|
||||||
export interface CreateTenderData {
|
export interface CreateTenderData {
|
||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
|
issueNumber?: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user