diff --git a/backend/prisma/migrations/20260603000000_add_tender_issue_number/migration.sql b/backend/prisma/migrations/20260603000000_add_tender_issue_number/migration.sql new file mode 100644 index 0000000..5621b49 --- /dev/null +++ b/backend/prisma/migrations/20260603000000_add_tender_issue_number/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ba768a6..f36cb76 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 9d2dd79..8594117 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -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 diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index ad2c68c..53f1d22 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -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(); diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index e6ca370..6f00b43 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -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(); \ No newline at end of file diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts index 86bbc5d..6828f22 100644 --- a/backend/src/modules/tenders/tenders.routes.ts +++ b/backend/src/modules/tenders/tenders.routes.ts @@ -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(), diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 22ebb8a..95de1f7 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -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) { diff --git a/frontend/src/app/portal/expense-claims/page.tsx b/frontend/src/app/portal/expense-claims/page.tsx index d313fff..8ce626a 100644 --- a/frontend/src/app/portal/expense-claims/page.tsx +++ b/frontend/src/app/portal/expense-claims/page.tsx @@ -75,12 +75,22 @@ export default function PortalExpenseClaimsPage() { const [showModal, setShowModal] = useState(false); const [error, setError] = useState(null); const [form, setForm] = useState(initialForm); + const [editingId, setEditingId] = useState(null); + const [removeAttachmentIds, setRemoveAttachmentIds] = useState([]); + 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); - await portalAPI.submitExpenseClaim({ - items, - description: form.description.trim() || undefined, - attachments: form.attachments, + 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() { -
+
إجمالي الطلبات
@@ -232,6 +295,16 @@ export default function PortalExpenseClaimsPage() { + +
آخر تحديث
@@ -279,6 +352,24 @@ export default function PortalExpenseClaimsPage() { {getStatusLabel(claim.status)} + {claim.status === 'PENDING' && ( + + + + + )}
{claim.status === 'APPROVED' && claim.approvalNote ? (
@@ -454,8 +545,8 @@ export default function PortalExpenseClaimsPage() { setShowModal(false)} - title="كشف مصاريف جديد" + onClose={() => { setShowModal(false); resetForm(); }} + title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'} >
@@ -587,6 +678,36 @@ export default function PortalExpenseClaimsPage() { المرفقات + {editingId && existingAttachments.length > 0 && ( +
+
المرفقات الحالية:
+ {existingAttachments.map((a) => { + const isRemoved = removeAttachmentIds.includes(a.id); + return ( +
+ {a.originalName || a.id} + +
+ ); + })} +
+ )} +
diff --git a/frontend/src/app/portal/leave/page.tsx b/frontend/src/app/portal/leave/page.tsx index ce7875a..da23b18 100644 --- a/frontend/src/app/portal/leave/page.tsx +++ b/frontend/src/app/portal/leave/page.tsx @@ -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(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() {

إجازاتي

- - {statusInfo.label} - +
+ + {statusInfo.label} + + {l.status === 'PENDING' && ( + <> + + + + )} +
) })} @@ -213,7 +292,11 @@ export default function PortalLeavePage() {
{/* الفورم */} - setShowModal(false)} title="طلب إجازة جديد"> + { setShowModal(false); resetForm() }} + title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'} + >
{/* نوع الإجازة */} @@ -315,7 +398,7 @@ export default function PortalLeavePage() {
diff --git a/frontend/src/app/portal/loans/page.tsx b/frontend/src/app/portal/loans/page.tsx index 7c9e98f..9d16ab2 100644 --- a/frontend/src/app/portal/loans/page.tsx +++ b/frontend/src/app/portal/loans/page.tsx @@ -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(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) => { - setLoans((prev) => [loan, ...prev]) + 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() {

قروضي

)}
- - {statusInfo.label} - +
+ + {statusInfo.label} + + {loan.status === 'PENDING_HR' && ( +
+ + +
+ )} +
{loan.rejectedReason && (

سبب الرفض: {loan.rejectedReason}

@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
)} - setShowModal(false)} title="طلب قرض جديد"> + { setShowModal(false); resetForm() }} + title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'} + >
@@ -177,7 +225,7 @@ export default function PortalLoansPage() { إلغاء
diff --git a/frontend/src/app/portal/managed-expense-claims/page.tsx b/frontend/src/app/portal/managed-expense-claims/page.tsx index aee38e3..9e61af8 100644 --- a/frontend/src/app/portal/managed-expense-claims/page.tsx +++ b/frontend/src/app/portal/managed-expense-claims/page.tsx @@ -46,6 +46,7 @@ export default function ManagedExpenseClaimsPage() { const [claims, setClaims] = useState([]); 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(null); const [payingId, setPayingId] = useState(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() {
+ +
+ + +
diff --git a/frontend/src/app/portal/overtime/page.tsx b/frontend/src/app/portal/overtime/page.tsx index 79b86e1..75ddb55 100644 --- a/frontend/src/app/portal/overtime/page.tsx +++ b/frontend/src/app/portal/overtime/page.tsx @@ -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(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) - await portalAPI.submitOvertimeRequest({ - date: form.date, - hours, - reason: form.reason.trim(), - }) - toast.success('تم إرسال الطلب') + 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() { + + + )} + ) })} @@ -131,7 +174,11 @@ export default function PortalOvertimePage() { )} - setOpen(false)} title="طلب ساعات إضافية"> + { setOpen(false); resetForm() }} + title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'} + >
@@ -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 />
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
diff --git a/frontend/src/app/portal/purchase-requests/page.tsx b/frontend/src/app/portal/purchase-requests/page.tsx index 6cac147..e417d63 100644 --- a/frontend/src/app/portal/purchase-requests/page.tsx +++ b/frontend/src/app/portal/purchase-requests/page.tsx @@ -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(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) => { - setRequests((prev) => [pr, ...prev]) + 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() {

طلبات الشراء

- - {statusInfo.label} - +
+ + {statusInfo.label} + + {pr.status === 'PENDING' && ( +
+ + +
+ )} +
) @@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() { )} - setShowModal(false)} title="طلب شراء جديد"> + { setShowModal(false); resetForm() }} + title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'} + >
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() { إلغاء
diff --git a/frontend/src/app/tenders/[id]/page.tsx b/frontend/src/app/tenders/[id]/page.tsx index 6c4782f..4395eac 100644 --- a/frontend/src/app/tenders/[id]/page.tsx +++ b/frontend/src/app/tenders/[id]/page.tsx @@ -87,10 +87,13 @@ function TenderDetailContent() { const [completeNotes, setCompleteNotes] = useState('') const [directiveTypeValues, setDirectiveTypeValues] = useState([]) const [submitting, setSubmitting] = useState(false) - const fileInputRef = useRef(null) const directiveFileInputRef = useRef(null) const [uploadingDirectiveId, setUploadingDirectiveId] = useState(null) const [directiveIdForUpload, setDirectiveIdForUpload] = useState(null) + const [uploadingCategory, setUploadingCategory] = useState(null) + const termsInputRef = useRef(null) + const costInputRef = useRef(null) + const offersInputRef = useRef(null) const fetchTender = async () => { try { @@ -213,11 +216,15 @@ function TenderDetailContent() { } } - const handleTenderFileUpload = async (e: React.ChangeEvent) => { + const handleTenderFileUpload = async ( + e: React.ChangeEvent, + 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,66 +533,102 @@ function TenderDetailContent() { {activeTab === 'attachments' && (
-
- - -
+ {(() => { + const all = (tender.attachments || []) as any[] + const sections: Array<{ + key: string + label: string + category: string + ref: React.RefObject + }> = [ + { 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 }, + ] - {!tender.attachments?.length ? ( -

{t('common.noData')}

- ) : ( -
    - {tender.attachments.map((a: any) => ( -
  • - - - {getDisplayFileName(a)} - + // 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 ( +
+ {sections.map((section) => { + const items = all.filter((a) => inSection(a, section.category)) + const isUploading = uploadingCategory === section.category + return ( +
+
+

{section.label}

+
+ handleTenderFileUpload(e, section.category)} + /> + +
+
+ + {items.length === 0 ? ( +

{t('common.noData')}

+ ) : ( +
    + {items.map((a: any) => ( +
  • + + + {getDisplayFileName(a)} + + +
  • + ))} +
+ )} +
+ ) + })} +
+ ) + })()}
)} diff --git a/frontend/src/app/tenders/page.tsx b/frontend/src/app/tenders/page.tsx index 6728e08..a6abada 100644 --- a/frontend/src/app/tenders/page.tsx +++ b/frontend/src/app/tenders/page.tsx @@ -58,6 +58,7 @@ const ANNOUNCEMENT_LABELS: Record = { 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() {
+
+ + setFormData({ ...formData, issueNumber: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + placeholder="رقم العدد (اختياري)" + /> +
+