edit for portal & tender

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

View File

@@ -0,0 +1,3 @@
-- Add issue_number column for tenders + supporting index
ALTER TABLE "tenders" ADD COLUMN "issueNumber" TEXT;
CREATE INDEX "tenders_issueNumber_idx" ON "tenders"("issueNumber");

View File

@@ -921,6 +921,7 @@ model Invoice {
model Tender {
id String @id @default(uuid())
tenderNumber String @unique
issueNumber String?
issuingBodyName String
title String
termsValue Decimal @db.Decimal(15, 2)
@@ -943,6 +944,7 @@ model Tender {
attachments Attachment[]
convertedDeal Deal?
@@index([tenderNumber])
@@index([issueNumber])
@@index([status])
@@index([createdById])
@@index([announcementDate])

View File

@@ -68,9 +68,13 @@ router.use(authenticate);
router.get('/portal/me', portalController.getMe);
router.get('/portal/loans', portalController.getMyLoans);
router.post('/portal/loans', portalController.submitLoanRequest);
router.put('/portal/loans/:id', portalController.updateMyLoan);
router.delete('/portal/loans/:id', portalController.deleteMyLoan);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest);
router.put('/portal/leaves/:id', portalController.updateMyLeave);
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
router.get(
'/portal/managed-leaves',
@@ -92,6 +96,8 @@ router.post(
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
router.put('/portal/overtime-requests/:attendanceId', portalController.updateMyOvertimeRequest);
router.delete('/portal/overtime-requests/:attendanceId', portalController.deleteMyOvertimeRequest);
router.get(
'/portal/managed-overtime-requests',
@@ -113,6 +119,8 @@ router.post(
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.put('/portal/purchase-requests/:id', portalController.updateMyPurchaseRequest);
router.delete('/portal/purchase-requests/:id', portalController.deleteMyPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries);
@@ -134,6 +142,22 @@ router.post(
},
portalController.submitExpenseClaim
);
router.put(
'/portal/expense-claims/:id',
(req, res, next) => {
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
if (error) {
return res.status(400).json({
success: false,
message: error.message || 'تعذر رفع المرفقات',
});
}
next();
});
},
portalController.updateMyExpenseClaim
);
router.delete('/portal/expense-claims/:id', portalController.deleteMyExpenseClaim);
router.get(
'/portal/expense-claims/attachments/:attachmentId/view',
portalController.viewExpenseClaimAttachment

View File

@@ -295,7 +295,13 @@ export class PortalController {
try {
const status = req.query.status as string | undefined;
const search = req.query.search as string | undefined;
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status, search);
const paid = req.query.paid as string | undefined;
const data = await portalService.getManagedExpenseClaims(
req.user?.employeeId,
status,
search,
paid,
);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
@@ -375,6 +381,178 @@ export class PortalController {
next(error);
}
}
// ========== PERSONAL EDIT/DELETE (pending only) ==========
async updateMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const body = { ...req.body };
const leaveType = body.leaveType ? String(body.leaveType).toUpperCase() : undefined;
let startDate: Date | undefined;
let endDate: Date | undefined;
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
} else if (body.startDate || body.endDate) {
startDate = body.startDate ? new Date(body.startDate) : undefined;
endDate = body.endDate ? new Date(body.endDate) : undefined;
}
const result = await portalService.updateMyLeave(
req.user?.employeeId,
req.params.id,
{
leaveType,
startDate,
endDate,
reason: body.reason,
},
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الإجازة'));
} catch (error) {
next(error);
}
}
async deleteMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyLeave(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الإجازة'));
} catch (error) {
next(error);
}
}
async updateMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyPurchaseRequest(
req.user?.employeeId,
req.params.id,
req.body,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الشراء'));
} catch (error) {
next(error);
}
}
async deleteMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyPurchaseRequest(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الشراء'));
} catch (error) {
next(error);
}
}
async updateMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyLoan(
req.user?.employeeId,
req.params.id,
req.body,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب القرض'));
} catch (error) {
next(error);
}
}
async deleteMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyLoan(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب القرض'));
} catch (error) {
next(error);
}
}
async updateMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
{ hours: req.body.hours, reason: req.body.reason },
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async deleteMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async updateMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const body = { ...req.body };
if (typeof body.items === 'string') {
body.items = JSON.parse(body.items);
}
if (typeof body.removeAttachmentIds === 'string') {
try {
body.removeAttachmentIds = JSON.parse(body.removeAttachmentIds);
} catch {
body.removeAttachmentIds = [];
}
}
const files = (req.files as Express.Multer.File[] | undefined) || [];
const data = await portalService.updateMyExpenseClaim(
req.user?.employeeId,
req.params.id,
body,
req.user!.id,
files
);
res.json(ResponseFormatter.success(data, 'تم تعديل كشف المصاريف'));
} catch (error: any) {
if (error.message?.includes('نوع الملف غير مدعوم')) {
return res.status(400).json({ success: false, message: error.message });
}
next(error);
}
}
async deleteMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyExpenseClaim(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف كشف المصاريف'));
} catch (error) {
next(error);
}
}
}
export const portalController = new PortalController();

View File

@@ -3,6 +3,7 @@ import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service';
import { notificationsService } from '../notifications/notifications.service';
import path from 'path';
import fs from 'fs';
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
@@ -730,6 +731,7 @@ return claimWithAttachments;
employeeId: string | undefined,
status?: string,
search?: string,
paid?: string,
) {
this.requireEmployeeId(employeeId);
@@ -739,6 +741,12 @@ return claimWithAttachments;
where.status = status;
}
if (paid === 'paid') {
where.isPaid = true;
} else if (paid === 'unpaid') {
where.isPaid = false;
}
const trimmedSearch = search?.trim();
if (trimmedSearch) {
where.employee = {
@@ -975,6 +983,376 @@ return claimWithAttachments;
take: 24,
});
}
// ========== PERSONAL PORTAL EDIT/DELETE (PENDING-only) ==========
// These actions are restricted to the request owner and only while the
// request is still in its initial pending state.
// ---------- Leaves ----------
async updateMyLeave(
employeeId: string | undefined,
leaveId: string,
data: { leaveType?: string; startDate?: Date; endDate?: Date; reason?: string },
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
if (leave.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
// Delete and re-create through the normal validated path so we
// benefit from leave-balance checks and audit logging.
await prisma.leave.delete({ where: { id: leaveId } });
return hrService.createLeaveRequest(
{
employeeId: empId,
leaveType: data.leaveType ?? leave.leaveType,
startDate: data.startDate ?? leave.startDate,
endDate: data.endDate ?? leave.endDate,
reason: data.reason !== undefined ? data.reason : leave.reason || undefined,
},
userId
);
}
async deleteMyLeave(employeeId: string | undefined, leaveId: string, _userId: string) {
const empId = this.requireEmployeeId(employeeId);
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
if (leave.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.leave.delete({ where: { id: leaveId } });
return { success: true };
}
// ---------- Purchase requests ----------
async updateMyPurchaseRequest(
employeeId: string | undefined,
requestId: string,
data: { items?: any[]; reason?: string; priority?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const items = Array.isArray(data.items) ? data.items : (existing.items as any[]) || [];
const totalAmount = items.reduce(
(s: number, i: any) =>
s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)),
0
);
return prisma.purchaseRequest.update({
where: { id: requestId },
data: {
items,
totalAmount,
reason: data.reason !== undefined ? data.reason : existing.reason,
priority: data.priority ?? existing.priority,
},
});
}
async deleteMyPurchaseRequest(
employeeId: string | undefined,
requestId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.purchaseRequest.delete({ where: { id: requestId } });
return { success: true };
}
// ---------- Loans ----------
async updateMyLoan(
employeeId: string | undefined,
loanId: string,
data: { type?: string; amount?: number; installments?: number; reason?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING_HR') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const amount = data.amount !== undefined ? Number(data.amount) : Number(existing.amount);
const installments =
data.installments !== undefined ? Number(data.installments) : existing.installments;
const monthlyAmount = installments > 0 ? amount / installments : amount;
return prisma.loan.update({
where: { id: loanId },
data: {
type: data.type ?? existing.type,
amount,
installments,
monthlyAmount,
reason: data.reason !== undefined ? data.reason : existing.reason,
},
});
}
async deleteMyLoan(employeeId: string | undefined, loanId: string, _userId: string) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING_HR') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.loan.delete({ where: { id: loanId } });
return { success: true };
}
// ---------- Overtime requests (stored as attendance rows) ----------
async updateMyOvertimeRequest(
employeeId: string | undefined,
attendanceId: string,
data: { hours?: number; reason?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
if (att.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
const parsed = this.parseOvertimeRequestNote(att.notes);
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
if (parsed.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const hours = data.hours !== undefined ? Number(data.hours) : parsed.hours;
const reason = data.reason !== undefined ? data.reason : parsed.reason;
if (!hours || hours <= 0) throw new AppError(400, 'عدد الساعات غير صالح');
if (!reason || !String(reason).trim()) throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
const updatedNote = this.buildOvertimeRequestNote(hours, String(reason).trim(), 'PENDING');
const updated = await prisma.attendance.update({
where: { id: attendanceId },
data: { overtimeHours: hours, notes: updatedNote },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
return this.formatOvertimeRequest(updated);
}
async deleteMyOvertimeRequest(
employeeId: string | undefined,
attendanceId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
if (att.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
const parsed = this.parseOvertimeRequestNote(att.notes);
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
if (parsed.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
// Clear the overtime request markers but keep the attendance row intact.
await prisma.attendance.update({
where: { id: attendanceId },
data: { overtimeHours: 0, notes: null },
});
return { success: true };
}
// ---------- Expense claims ----------
async updateMyExpenseClaim(
employeeId: string | undefined,
claimId: string,
data: {
items?: Array<{
expenseDate?: string;
amount?: number | string;
entityName?: string;
description?: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
removeAttachmentIds?: string[];
},
userId: string,
newFiles?: Express.Multer.File[]
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const items = Array.isArray(data.items) ? data.items : [];
const normalizedItems = items
.map((item) => ({
expenseDate: item.expenseDate || '',
amount: Number(item.amount || 0),
entityName: item.entityName?.trim() || '',
description: item.description?.trim() || '',
projectOrTender: item.projectOrTender?.trim() || '',
proofRef: item.proofRef?.trim() || '',
}))
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
if (normalizedItems.length === 0) {
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
}
const totalAmount = normalizedItems.reduce(
(sum, item) => sum + Number(item.amount || 0),
0
);
const firstItem = normalizedItems[0];
// Remove selected attachments (DB + file on disk).
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
const attachments = await prisma.attachment.findMany({
where: {
id: { in: data.removeAttachmentIds },
entityType: 'EXPENSE_CLAIM',
entityId: claimId,
},
});
for (const a of attachments) {
try {
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
} catch {
/* swallow */
}
}
await prisma.attachment.deleteMany({
where: { id: { in: data.removeAttachmentIds }, entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
}
const updated = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
items: normalizedItems as any,
totalAmount,
expenseDate: new Date(firstItem.expenseDate),
amount: totalAmount,
description: data.description?.trim() || null,
projectOrTender: firstItem.projectOrTender || null,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (newFiles && newFiles.length > 0) {
await Promise.all(
newFiles.map((file) =>
prisma.attachment.create({
data: {
entityType: 'EXPENSE_CLAIM',
entityId: claimId,
fileName: path.basename(file.path),
originalName: (file as any).decodedOriginalName || file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: 'EXPENSE_CLAIM_ATTACHMENT',
uploadedBy: userId,
},
})
)
);
}
const [withFiles] = await this.attachExpenseClaimFiles([updated]);
return withFiles;
}
async deleteMyExpenseClaim(
employeeId: string | undefined,
claimId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
const attachments = await prisma.attachment.findMany({
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
for (const a of attachments) {
try {
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
} catch {
/* swallow */
}
}
await prisma.attachment.deleteMany({
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
await prisma.expenseClaim.delete({ where: { id: claimId } });
return { success: true };
}
}
export const portalService = new PortalService();

View File

@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
// but multer/busboy decode them as latin1 by default. Reverse it so
// Arabic filenames are stored intact in the DB.
try {
const decoded = Buffer.from(file.originalname || '', 'latin1').toString('utf8');
file.originalname = decoded;
} catch {
// keep as-is
}
const extName = path.extname(file.originalname || '') || '';
cb(null, `${crypto.randomUUID()}${extName}`);
},
});
const upload = multer({
@@ -93,6 +102,7 @@ router.post(
authorize('tenders', 'tenders', 'create'),
[
body('tenderNumber').notEmpty().trim(),
body('issueNumber').optional().trim(),
body('issuingBodyName').notEmpty().trim(),
body('title').notEmpty().trim(),
body('termsValue').isNumeric(),

View File

@@ -36,6 +36,7 @@ export interface CreateTenderData {
issuingBodyName: string;
title: string;
tenderNumber: string;
issueNumber?: string;
termsValue: number;
bondValue: number;
@@ -353,6 +354,7 @@ private getEffectiveTenderStatus(tender: {
const tender = await prisma.tender.create({
data: {
tenderNumber,
issueNumber: data.issueNumber?.trim() || null,
issuingBodyName: data.issuingBodyName.trim(),
title: data.title.trim(),
termsValue: data.termsValue,
@@ -392,6 +394,7 @@ private getEffectiveTenderStatus(tender: {
if (filters.search) {
where.OR = [
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
{ title: { contains: filters.search, mode: 'insensitive' } },
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
];
@@ -496,6 +499,9 @@ private getEffectiveTenderStatus(tender: {
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
}
if (data.title !== undefined) updateData.title = data.title.trim();
if (data.issueNumber !== undefined) {
updateData.issueNumber = data.issueNumber?.trim() || null;
}
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {

View File

@@ -75,12 +75,22 @@ export default function PortalExpenseClaimsPage() {
const [showModal, setShowModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [removeAttachmentIds, setRemoveAttachmentIds] = useState<string[]>([]);
const [existingAttachments, setExistingAttachments] = useState<
Array<{ id: string; originalName?: string; mimeType?: string }>
>([]);
const [statusFilter, setStatusFilter] = useState('all');
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const filteredClaims = useMemo(() => {
if (statusFilter === 'all') return claims;
return claims.filter((claim) => claim.status === statusFilter);
}, [claims, statusFilter]);
return claims.filter((claim) => {
if (statusFilter !== 'all' && claim.status !== statusFilter) return false;
if (paidFilter === 'paid' && !claim.isPaid) return false;
if (paidFilter === 'unpaid' && claim.isPaid) return false;
return true;
});
}, [claims, statusFilter, paidFilter]);
const totalAmount = useMemo(() => {
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
@@ -130,6 +140,51 @@ export default function PortalExpenseClaimsPage() {
}));
};
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
setRemoveAttachmentIds([]);
setExistingAttachments([]);
};
const openEdit = (claim: ExpenseClaim) => {
setEditingId(claim.id);
const items = Array.isArray(claim.items) && claim.items.length > 0
? claim.items.map((it: any) => ({
expenseDate: it.expenseDate ? String(it.expenseDate).split('T')[0] : '',
amount: String(it.amount ?? ''),
entityName: it.entityName || '',
description: it.description || '',
projectOrTender: it.projectOrTender || '',
proofRef: it.proofRef || '',
}))
: [emptyLine()];
setForm({
items,
description: claim.description || '',
attachments: [],
});
setExistingAttachments(
(claim.attachments || []).map((a) => ({
id: a.id,
originalName: a.originalName,
mimeType: a.mimeType,
}))
);
setRemoveAttachmentIds([]);
setShowModal(true);
};
const handleDelete = async (id: string) => {
if (!confirm('حذف كشف المصاريف؟')) return;
try {
await portalAPI.deleteExpenseClaim(id);
setClaims((prev) => prev.filter((c) => c.id !== id));
} catch (err: any) {
alert(err?.response?.data?.message || 'فشل الحذف');
}
};
async function openAttachment(attachment: any) {
try {
@@ -175,14 +230,22 @@ export default function PortalExpenseClaimsPage() {
try {
setSubmitting(true);
if (editingId) {
await portalAPI.updateExpenseClaim(editingId, {
items,
description: form.description.trim() || undefined,
attachments: form.attachments,
removeAttachmentIds,
});
} else {
await portalAPI.submitExpenseClaim({
items,
description: form.description.trim() || undefined,
attachments: form.attachments,
});
}
setForm(initialForm);
resetForm();
setShowModal(false);
await loadClaims();
} catch (err: any) {
@@ -206,14 +269,14 @@ export default function PortalExpenseClaimsPage() {
</div>
<button
onClick={() => setShowModal(true)}
onClick={() => { resetForm(); setShowModal(true); }}
className="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
>
+ طلب كشف مصاريف
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
<div className="mt-2 text-2xl font-bold text-gray-900">
@@ -232,6 +295,16 @@ export default function PortalExpenseClaimsPage() {
<option value="REJECTED">مرفوض</option>
</select>
<select
value={paidFilter}
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">القبض: الكل</option>
<option value="paid">مقبوض</option>
<option value="unpaid">غير مقبوض</option>
</select>
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">آخر تحديث</div>
<div className="mt-2 text-base font-semibold text-gray-900">
@@ -279,6 +352,24 @@ export default function PortalExpenseClaimsPage() {
{getStatusLabel(claim.status)}
</span>
{claim.status === 'PENDING' && (
<span className="inline-flex gap-2">
<button
type="button"
onClick={() => openEdit(claim)}
className="text-xs text-teal-600 hover:underline"
>
تعديل
</button>
<button
type="button"
onClick={() => handleDelete(claim.id)}
className="text-xs text-red-600 hover:underline"
>
حذف
</button>
</span>
)}
</div>
{claim.status === 'APPROVED' && claim.approvalNote ? (
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
@@ -454,8 +545,8 @@ export default function PortalExpenseClaimsPage() {
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="كشف مصاريف جديد"
onClose={() => { setShowModal(false); resetForm(); }}
title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -587,6 +678,36 @@ export default function PortalExpenseClaimsPage() {
المرفقات
</label>
{editingId && existingAttachments.length > 0 && (
<div className="mb-2 space-y-1">
<div className="text-xs text-gray-500">المرفقات الحالية:</div>
{existingAttachments.map((a) => {
const isRemoved = removeAttachmentIds.includes(a.id);
return (
<div
key={a.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
isRemoved ? 'bg-red-50 border-red-200 text-red-600 line-through' : 'bg-gray-50 text-gray-700'
}`}
>
<span className="truncate">{a.originalName || a.id}</span>
<button
type="button"
onClick={() => {
setRemoveAttachmentIds((prev) =>
isRemoved ? prev.filter((id) => id !== a.id) : [...prev, a.id]
);
}}
className="text-red-600 hover:underline shrink-0 ms-2"
>
{isRemoved ? 'استرجاع' : 'إزالة'}
</button>
</div>
);
})}
</div>
)}
<input
type="file"
multiple
@@ -647,7 +768,7 @@ export default function PortalExpenseClaimsPage() {
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
onClick={() => { setShowModal(false); resetForm(); }}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
إلغاء
@@ -658,7 +779,7 @@ export default function PortalExpenseClaimsPage() {
disabled={submitting}
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'جارٍ الإرسال...' : 'إرسال الطلب'}
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button>
</div>
</form>

View File

@@ -32,9 +32,10 @@ const toCompanyDateTime = (date: string, time: string) => {
}
const formatCompanyTime = (value: string) => {
return new Date(value).toLocaleTimeString('en-US', {
return new Date(value).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: COMPANY_TIME_ZONE,
})
}
@@ -51,6 +52,7 @@ export default function PortalLeavePage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({
leaveType: 'ANNUAL',
@@ -74,6 +76,67 @@ export default function PortalLeavePage() {
}
useEffect(() => load(), [])
const resetForm = () => {
setForm({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
setEditingId(null)
}
const openEdit = (l: any) => {
setEditingId(l.id)
if (l.leaveType === 'HOURLY') {
const start = new Date(l.startDate)
const end = new Date(l.endDate)
const dateStr = start.toLocaleDateString('en-CA', { timeZone: COMPANY_TIME_ZONE })
const fmt = (d: Date) =>
d.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: COMPANY_TIME_ZONE,
})
setForm({
leaveType: 'HOURLY',
startDate: '',
endDate: '',
leaveDate: dateStr,
startTime: fmt(start),
endTime: fmt(end),
reason: l.reason || '',
})
} else {
setForm({
leaveType: 'ANNUAL',
startDate: String(l.startDate).split('T')[0],
endDate: String(l.endDate).split('T')[0],
leaveDate: '',
startTime: '',
endTime: '',
reason: l.reason || '',
})
}
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الإجازة؟')) return
try {
await portalAPI.deleteLeaveRequest(id)
toast.success('تم حذف الطلب')
load()
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -115,19 +178,15 @@ export default function PortalLeavePage() {
setSubmitting(true)
portalAPI.submitLeaveRequest(payload)
const action = editingId
? portalAPI.updateLeaveRequest(editingId, payload)
: portalAPI.submitLeaveRequest(payload)
action
.then(() => {
setShowModal(false)
setForm({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
toast.success('تم إرسال طلب الإجازة')
resetForm()
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
load()
})
.catch((err: any) => {
@@ -151,7 +210,7 @@ export default function PortalLeavePage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button
onClick={() => setShowModal(true)}
onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
@@ -202,9 +261,29 @@ export default function PortalLeavePage() {
</p>
</div>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
{l.status === 'PENDING' && (
<>
<button
type="button"
onClick={() => openEdit(l)}
className="text-xs text-teal-600 hover:underline"
>
تعديل
</button>
<button
type="button"
onClick={() => handleDelete(l.id)}
className="text-xs text-red-600 hover:underline"
>
حذف
</button>
</>
)}
</div>
</div>
)
})}
@@ -213,7 +292,11 @@ export default function PortalLeavePage() {
</div>
{/* الفورم */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* نوع الإجازة */}
@@ -315,7 +398,7 @@ export default function PortalLeavePage() {
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowModal(false)}
onClick={() => { setShowModal(false); resetForm() }}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
إلغاء
@@ -326,7 +409,7 @@ export default function PortalLeavePage() {
disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button>
</div>

View File

@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
useEffect(() => {
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
.finally(() => setLoading(false))
}, [])
const resetForm = () => {
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
setEditingId(null)
}
const openEdit = (loan: Loan) => {
setEditingId(loan.id)
setForm({
type: loan.type,
amount: String(loan.amount ?? ''),
installments: String(loan.installments ?? '1'),
reason: loan.reason || '',
})
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب القرض؟')) return
try {
await portalAPI.deleteLoanRequest(id)
toast.success('تم الحذف')
setLoans((prev) => prev.filter((l) => l.id !== id))
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(form.amount)
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
}
setSubmitting(true)
portalAPI.submitLoanRequest({
const payload = {
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason.trim(),
})
}
const action = editingId
? portalAPI.updateLoanRequest(editingId, payload)
: portalAPI.submitLoanRequest(payload)
action
.then((loan) => {
if (editingId) {
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
} else {
setLoans((prev) => [loan, ...prev])
}
setShowModal(false)
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
toast.success('تم إرسال طلب القرض')
resetForm()
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
<button
onClick={() => setShowModal(true)}
onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
</div>
)}
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
{loan.status === 'PENDING_HR' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(loan)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(loan.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div>
{loan.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button>
</div>
</form>

View File

@@ -46,6 +46,7 @@ export default function ManagedExpenseClaimsPage() {
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('PENDING');
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [payingId, setPayingId] = useState<string | null>(null);
@@ -57,12 +58,17 @@ export default function ManagedExpenseClaimsPage() {
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
async function loadClaims(status = statusFilter, search = searchQuery) {
async function loadClaims(
status = statusFilter,
search = searchQuery,
paid: 'all' | 'paid' | 'unpaid' = paidFilter,
) {
try {
setLoading(true);
const data = await portalAPI.getManagedExpenseClaims(
status === 'all' ? undefined : status,
search.trim() || undefined,
paid,
);
setClaims(data);
} catch (error: any) {
@@ -75,11 +81,11 @@ export default function ManagedExpenseClaimsPage() {
useEffect(() => {
// Debounce the search so we don't fire a request on every keystroke.
const handle = setTimeout(() => {
loadClaims(statusFilter, searchQuery);
loadClaims(statusFilter, searchQuery, paidFilter);
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, searchQuery]);
}, [statusFilter, searchQuery, paidFilter]);
async function openAttachment(attachment: any) {
try {
@@ -108,7 +114,7 @@ export default function ManagedExpenseClaimsPage() {
try {
setSubmittingId(id);
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
await loadClaims(statusFilter, searchQuery);
await loadClaims(statusFilter, searchQuery, paidFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
} finally {
@@ -141,7 +147,7 @@ export default function ManagedExpenseClaimsPage() {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
await loadClaims(statusFilter, searchQuery);
await loadClaims(statusFilter, searchQuery, paidFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
} finally {
@@ -229,6 +235,19 @@ export default function ManagedExpenseClaimsPage() {
<option value="all">الكل</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">القبض:</label>
<select
value={paidFilter}
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">الكل</option>
<option value="paid">مقبوض</option>
<option value="unpaid">غير مقبوض</option>
</select>
</div>
</div>
</div>

View File

@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [open, setOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({
date: '',
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
loadData()
}, [])
const resetForm = () => {
setForm({ date: '', hours: '', reason: '' })
setEditingId(null)
}
const openEdit = (item: PortalOvertimeRequest) => {
setEditingId(item.attendanceId || item.id)
setForm({
date: String(item.date).split('T')[0],
hours: String(item.hours ?? ''),
reason: item.reason || '',
})
setOpen(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الساعات الإضافية؟')) return
try {
await portalAPI.deleteOvertimeRequest(id)
toast.success('تم الحذف')
loadData()
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
try {
setSubmitting(true)
if (editingId) {
await portalAPI.updateOvertimeRequest(editingId, {
hours,
reason: form.reason.trim(),
})
toast.success('تم تعديل الطلب')
} else {
await portalAPI.submitOvertimeRequest({
date: form.date,
hours,
reason: form.reason.trim(),
})
toast.success('تم إرسال الطلب')
}
setOpen(false)
setForm({ date: '', hours: '', reason: '' })
resetForm()
loadData()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
</div>
<button
onClick={() => setOpen(true)}
onClick={() => { resetForm(); setOpen(true) }}
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
{meta.label}
</span>
{item.status === 'PENDING' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(item)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(item.attendanceId || item.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div>
)
})}
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
)}
</div>
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
<Modal
isOpen={open}
onClose={() => { setOpen(false); resetForm() }}
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
value={form.date}
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
disabled={!!editingId}
required
/>
</div>
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
onClick={() => { setOpen(false); resetForm() }}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
إلغاء
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
</button>
</div>
</form>

View File

@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
reason: '',
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
}))
const resetForm = () => {
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
setEditingId(null)
}
const openEdit = (pr: PurchaseRequest) => {
setEditingId(pr.id)
const items = Array.isArray(pr.items) && pr.items.length > 0
? pr.items.map((it: any) => ({
description: String(it.description || ''),
quantity: Number(it.quantity || 1),
estimatedPrice: String(it.estimatedPrice ?? ''),
}))
: [{ description: '', quantity: 1, estimatedPrice: '' }]
setForm({ items, reason: pr.reason || '', priority: pr.priority || 'NORMAL' })
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الشراء؟')) return
try {
await portalAPI.deletePurchaseRequest(id)
toast.success('تم الحذف')
setRequests((prev) => prev.filter((r) => r.id !== id))
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const items = form.items
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
return
}
setSubmitting(true)
portalAPI.submitPurchaseRequest({
items,
reason: form.reason || undefined,
priority: form.priority,
})
const payload = { items, reason: form.reason || undefined, priority: form.priority }
const action = editingId
? portalAPI.updatePurchaseRequest(editingId, payload)
: portalAPI.submitPurchaseRequest(payload)
action
.then((pr) => {
if (editingId) {
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
} else {
setRequests((prev) => [pr, ...prev])
}
setShowModal(false)
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
toast.success('تم إرسال طلب الشراء')
resetForm()
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
<button
onClick={() => setShowModal(true)}
onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
@@ -121,9 +155,17 @@ export default function PortalPurchaseRequestsPage() {
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
)}
</div>
<div className="flex flex-col items-end gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
{pr.status === 'PENDING' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(pr)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(pr.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div>
</div>
)
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div>
<div className="flex justify-between items-center mb-2">
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button>
</div>
</form>

View File

@@ -87,10 +87,13 @@ function TenderDetailContent() {
const [completeNotes, setCompleteNotes] = useState('')
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const directiveFileInputRef = useRef<HTMLInputElement>(null)
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
const termsInputRef = useRef<HTMLInputElement>(null)
const costInputRef = useRef<HTMLInputElement>(null)
const offersInputRef = useRef<HTMLInputElement>(null)
const fetchTender = async () => {
try {
@@ -213,11 +216,15 @@ function TenderDetailContent() {
}
}
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleTenderFileUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
category?: string,
) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
setSubmitting(true)
if (category) setUploadingCategory(category)
else setSubmitting(true)
let successCount = 0
let failCount = 0
@@ -225,7 +232,7 @@ function TenderDetailContent() {
// Upload files sequentially so a failure of one file doesn't break the rest.
for (const file of files) {
try {
await tendersAPI.uploadTenderAttachment(tenderId, file)
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
successCount++
} catch (err: any) {
failCount++
@@ -244,6 +251,7 @@ function TenderDetailContent() {
if (successCount > 0) fetchTender()
} finally {
setSubmitting(false)
setUploadingCategory(null)
e.target.value = ''
}
}
@@ -525,20 +533,50 @@ function TenderDetailContent() {
{activeTab === 'attachments' && (
<div>
<div className="flex items-center gap-4 mb-4">
{(() => {
const all = (tender.attachments || []) as any[]
const sections: Array<{
key: string
label: string
category: string
ref: React.RefObject<HTMLInputElement>
}> = [
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
]
// Legacy attachments without a recognized category live under
// the dafter section by default so nothing gets hidden.
const knownCategories = new Set(sections.map((s) => s.category))
const inSection = (a: any, category: string) =>
a.category === category ||
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
return (
<div className="space-y-6">
{sections.map((section) => {
const items = all.filter((a) => inSection(a, section.category))
const isUploading = uploadingCategory === section.category
return (
<div key={section.key} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
<div>
<input
type="file"
ref={fileInputRef}
ref={section.ref}
multiple
className="hidden"
onChange={handleTenderFileUpload}
onChange={(e) => handleTenderFileUpload(e, section.category)}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
type="button"
onClick={() => section.ref.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
>
{submitting ? (
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
@@ -546,12 +584,13 @@ function TenderDetailContent() {
{t('tenders.uploadFile')}
</button>
</div>
</div>
{!tender.attachments?.length ? (
<p className="text-gray-500">{t('common.noData')}</p>
{items.length === 0 ? (
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{tender.attachments.map((a: any) => (
{items.map((a: any) => (
<li
key={a.id}
className="flex items-center justify-between border rounded px-3 py-2"
@@ -565,7 +604,6 @@ function TenderDetailContent() {
<ExternalLink className="h-4 w-4" />
{getDisplayFileName(a)}
</a>
<button
onClick={async () => {
if (!confirm('حذف الملف؟')) return
@@ -586,6 +624,12 @@ function TenderDetailContent() {
</ul>
)}
</div>
)
})}
</div>
)
})()}
</div>
)}
{activeTab === 'history' && (

View File

@@ -58,6 +58,7 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
const getInitialFormData = (): CreateTenderData => ({
tenderNumber: '',
issueNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
@@ -114,6 +115,7 @@ function TendersContent() {
const fillFormFromTender = (tender: Tender): CreateTenderData => ({
tenderNumber: tender.tenderNumber || '',
issueNumber: tender.issueNumber || '',
issuingBodyName: tender.issuingBodyName || '',
title: tender.title || '',
termsValue: Number(tender.termsValue || 0),
@@ -316,6 +318,19 @@ function TendersContent() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
رقم العدد
</label>
<input
type="text"
value={formData.issueNumber || ''}
onChange={(e) => setFormData({ ...formData, issueNumber: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="رقم العدد (اختياري)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.titleLabel')} *
@@ -684,6 +699,9 @@ function TendersContent() {
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('tenders.tenderNumber') || 'Number'}
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
رقم العدد
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
{t('tenders.title') || 'Title'}
</th>
@@ -708,6 +726,9 @@ function TendersContent() {
<td className="px-6 py-4 text-sm font-semibold text-gray-900 text-right align-middle">
{tender.tenderNumber}
</td>
<td className="px-6 py-4 text-sm text-gray-700 text-right align-middle">
{tender.issueNumber || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-900 text-right align-middle">
{tender.title}
</td>

View File

@@ -234,6 +234,18 @@ export const portalAPI = {
return response.data.data
},
updateLoanRequest: async (
id: string,
data: { type?: string; amount?: number; installments?: number; reason?: string }
): Promise<Loan> => {
const response = await api.put(`/hr/portal/loans/${id}`, data)
return response.data.data
},
deleteLoanRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/loans/${id}`)
},
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/overtime-requests')
return response.data.data || []
@@ -248,6 +260,18 @@ export const portalAPI = {
return response.data.data
},
updateOvertimeRequest: async (
attendanceId: string,
data: { hours?: number; reason?: string }
): Promise<PortalOvertimeRequest> => {
const response = await api.put(`/hr/portal/overtime-requests/${attendanceId}`, data)
return response.data.data
},
deleteOvertimeRequest: async (attendanceId: string): Promise<void> => {
await api.delete(`/hr/portal/overtime-requests/${attendanceId}`)
},
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/managed-overtime-requests')
return response.data.data || []
@@ -292,6 +316,26 @@ export const portalAPI = {
return response.data.data
},
updateLeaveRequest: async (
id: string,
data: {
leaveType?: string
startDate?: string
endDate?: string
leaveDate?: string
startTime?: string
endTime?: string
reason?: string
}
): Promise<Leave> => {
const response = await api.put(`/hr/portal/leaves/${id}`, data)
return response.data.data
},
deleteLeaveRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/leaves/${id}`)
},
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
const response = await api.get('/hr/portal/purchase-requests')
return response.data.data || []
@@ -302,6 +346,22 @@ export const portalAPI = {
return response.data.data
},
updatePurchaseRequest: async (
id: string,
data: {
items?: Array<{ description: string; quantity?: number; estimatedPrice?: number }>
reason?: string
priority?: string
}
): Promise<PurchaseRequest> => {
const response = await api.put(`/hr/portal/purchase-requests/${id}`, data)
return response.data.data
},
deletePurchaseRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/purchase-requests/${id}`)
},
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
const response = await api.get('/hr/portal/expense-claims')
return response.data.data || []
@@ -340,10 +400,50 @@ export const portalAPI = {
return response.data.data;
},
getManagedExpenseClaims: async (status?: string, search?: string): Promise<ExpenseClaim[]> => {
updateExpenseClaim: async (
id: string,
data: {
items: Array<{
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
attachments?: File[];
removeAttachmentIds?: string[];
}
): Promise<ExpenseClaim> => {
const formData = new FormData();
formData.append('items', JSON.stringify(data.items));
if (data.description) formData.append('description', data.description);
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
formData.append('removeAttachmentIds', JSON.stringify(data.removeAttachmentIds));
}
if (data.attachments && data.attachments.length > 0) {
for (const file of data.attachments) formData.append('attachments', file);
}
const response = await api.put(`/hr/portal/expense-claims/${id}`, formData, {
headers: { 'Content-Type': undefined as any },
});
return response.data.data;
},
deleteExpenseClaim: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/expense-claims/${id}`);
},
getManagedExpenseClaims: async (
status?: string,
search?: string,
paid?: 'all' | 'paid' | 'unpaid',
): Promise<ExpenseClaim[]> => {
const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status)
if (search && search.trim()) q.append('search', search.trim())
if (paid && paid !== 'all') q.append('paid', paid)
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
return response.data.data || []
},

View File

@@ -3,6 +3,7 @@ import { api } from '../api'
export interface Tender {
id: string
tenderNumber: string
issueNumber?: string | null
issuingBodyName: string
title: string
@@ -57,6 +58,7 @@ export interface TenderDirective {
export interface CreateTenderData {
tenderNumber: string
issueNumber?: string
issuingBodyName: string
title: string