diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ba70beb..177f305 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -302,8 +302,8 @@ model Leave { employeeId String employee Employee @relation(fields: [employeeId], references: [id]) leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc. - startDate DateTime @db.Date - endDate DateTime @db.Date + startDate DateTime @db.Timestamp(3) + endDate DateTime @db.Timestamp(3) days Int reason String? status String @default("PENDING") // PENDING, APPROVED, REJECTED @@ -523,6 +523,7 @@ model ExpenseClaim { approvedBy String? approvedAt DateTime? rejectedReason String? + approvalNote String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index e19dacc..8c2bb59 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -2,8 +2,50 @@ import { Router } from 'express'; import { hrController } from './hr.controller'; import { portalController } from './portal.controller'; import { authenticate, authorize } from '../../shared/middleware/auth'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { config } from '../../config'; const router = Router(); +const expenseClaimsUploadDir = path.join(config.upload.path, 'expense-claims'); + +if (!fs.existsSync(expenseClaimsUploadDir)) { + fs.mkdirSync(expenseClaimsUploadDir, { recursive: true }); +} + +const expenseClaimStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir), + filename: (_req, file, cb) => { + const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${crypto.randomUUID()}-${safeName}`); + }, +}); + +const expenseClaimUpload = multer({ + storage: expenseClaimStorage, + limits: { fileSize: config.upload.maxFileSize }, + + fileFilter: (_req, file, cb) => { + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + ]; + + if (!allowedTypes.includes(file.mimetype)) { + return cb( + new Error('نوع الملف غير مدعوم. يرجى رفع صورة أو ملف PDF.') + ); + } + + cb(null, true); + }, +}); + router.use(authenticate); // ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ========== @@ -60,13 +102,27 @@ router.get('/portal/attendance', portalController.getMyAttendance); router.get('/portal/salaries', portalController.getMySalaries); router.get('/portal/expense-claims', portalController.getMyExpenseClaims); -router.post('/portal/expense-claims', portalController.submitExpenseClaim); +router.post( + '/portal/expense-claims', + (req, res, next) => { + expenseClaimUpload.single('attachment')(req, res, (error: any) => { + if (error) { + return res.status(400).json({ + success: false, + message: error.message || 'تعذر رفع المرفق', + }); + } -router.get( - '/portal/managed-expense-claims', - authorize('department_expense_claims', '*', 'read'), - portalController.getManagedExpenseClaims + next(); + }); + }, + portalController.submitExpenseClaim ); +router.get( + '/portal/expense-claims/attachments/:attachmentId/view', + portalController.viewExpenseClaimAttachment +); +router.get('/portal/managed-expense-claims', authorize('department_expense_claims', '*', 'read'), portalController.getManagedExpenseClaims); router.post( '/portal/managed-expense-claims/:id/approve', diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index d19c09f..07b0764 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -1,7 +1,6 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; -import { notificationsService } from '../notifications/notifications.service'; class HRService { // ========== EMPLOYEES ========== @@ -353,40 +352,15 @@ class HRService { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'CREATE', userId, }); - const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`; - - await notificationsService.notifyApprovalRecipients({ - resource: 'leave_requests', - fallbackEmployeeId: leave.employeeId, - fallbackToManager: true, - type: 'LEAVE_REQUEST_SUBMITTED', - title: 'طلب إجازة جديد بانتظار الموافقة', - message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`, - entityType: 'LEAVE', - entityId: leave.id, - excludeUserIds: [userId], - }); - - await notificationsService.notifyEmployeeUser({ - employeeId: leave.employeeId, - type: 'LEAVE_REQUEST_CREATED', - title: 'تم إرسال طلب الإجازة', - message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.', - entityType: 'LEAVE', - entityId: leave.id, - excludeUserIds: [], - }); - return leave; } - async approveLeave(id: string, approvedBy: string, userId: string) { const leave = await prisma.leave.update({ where: { id }, @@ -404,25 +378,16 @@ class HRService { const year = new Date(leave.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'APPROVE', userId, }); - await notificationsService.notifyEmployeeUser({ - employeeId: leave.employeeId, - type: 'LEAVE_REQUEST_APPROVED', - title: 'تمت الموافقة على طلب الإجازة', - message: 'تمت الموافقة على طلب الإجازة الخاص بك.', - entityType: 'LEAVE', - entityId: leave.id, - excludeUserIds: [userId], - }); - return leave; } + async rejectLeave(id: string, rejectedReason: string, userId: string) { const leave = await prisma.leave.findUnique({ where: { id } }); if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found'); @@ -438,26 +403,13 @@ class HRService { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: id, action: 'REJECT', userId, reason: rejectedReason, }); - - await notificationsService.notifyEmployeeUser({ - employeeId: updated.employeeId, - type: 'LEAVE_REQUEST_REJECTED', - title: 'تم رفض طلب الإجازة', - message: rejectedReason - ? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}` - : 'تم رفض طلب الإجازة الخاص بك.', - entityType: 'LEAVE', - entityId: updated.id, - excludeUserIds: [userId], - }); - return updated; } @@ -614,23 +566,13 @@ async findManagedLeaves(status?: string) { const year = new Date(updated.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_APPROVE', userId, }); - await notificationsService.notifyEmployeeUser({ - employeeId: updated.employeeId, - type: 'LEAVE_REQUEST_APPROVED', - title: 'تمت الموافقة على طلب الإجازة', - message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.', - entityType: 'LEAVE', - entityId: updated.id, - excludeUserIds: [userId], - }); - return updated; } @@ -668,7 +610,7 @@ async findManagedLeaves(status?: string) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_REJECT', @@ -676,18 +618,6 @@ async findManagedLeaves(status?: string) { reason: rejectedReason, }); - await notificationsService.notifyEmployeeUser({ - employeeId: updated.employeeId, - type: 'LEAVE_REQUEST_REJECTED', - title: 'تم رفض طلب الإجازة', - message: rejectedReason - ? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}` - : 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.', - entityType: 'LEAVE', - entityId: updated.id, - excludeUserIds: [userId], - }); - return updated; } @@ -913,30 +843,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); - - const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`; - - await notificationsService.notifyApprovalRecipients({ - resource: 'loan_requests', - type: 'LOAN_REQUEST_SUBMITTED', - title: 'طلب قرض جديد بانتظار الموافقة', - message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`, - entityType: 'LOAN', - entityId: loan.id, - excludeUserIds: [userId], - }); - - await notificationsService.notifyEmployeeUser({ - employeeId: loan.employeeId, - type: 'LOAN_REQUEST_CREATED', - title: 'تم إرسال طلب القرض', - message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`, - entityType: 'LOAN', - entityId: loan.id, - excludeUserIds: [], - }); - + await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); return loan; } @@ -992,36 +899,13 @@ private isSystemAdminUser(user: any) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'HR_APPROVE_FORWARD_TO_ADMIN', userId, }); - const fullLoan = await this.findLoanById(id); - const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`; - - await notificationsService.notifyApprovalRecipients({ - resource: 'loan_requests', - type: 'LOAN_REQUEST_PENDING_ADMIN', - title: 'طلب قرض محال إلى مدير النظام', - message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`, - entityType: 'LOAN', - entityId: id, - excludeUserIds: [userId], - }); - - await notificationsService.notifyEmployeeUser({ - employeeId: fullLoan.employee.id, - type: 'LOAN_REQUEST_ESCALATED', - title: 'تمت إحالة طلب القرض للاعتماد النهائي', - message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`, - entityType: 'LOAN', - entityId: id, - excludeUserIds: [userId], - }); - return updatedLoan; } } @@ -1070,26 +954,14 @@ private isSystemAdminUser(user: any) { ), ]); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE', userId, }); - const approvedLoan = await this.findLoanById(id); - - await notificationsService.notifyEmployeeUser({ - employeeId: approvedLoan.employee.id, - type: 'LOAN_REQUEST_APPROVED', - title: 'تمت الموافقة على طلب القرض', - message: `تمت الموافقة على طلب القرض الخاص بك برقم ${approvedLoan.loanNumber}.`, - entityType: 'LOAN', - entityId: approvedLoan.id, - excludeUserIds: [userId], - }); - - return approvedLoan; + return this.findLoanById(id); } async rejectLoan(id: string, rejectedReason: string, userId: string) { @@ -1109,7 +981,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', @@ -1117,18 +989,6 @@ private isSystemAdminUser(user: any) { reason: rejectedReason, }); - await notificationsService.notifyEmployeeUser({ - employeeId: loan.employeeId, - type: 'LOAN_REQUEST_REJECTED', - title: 'تم رفض طلب القرض', - message: rejectedReason?.trim() - ? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}` - : 'تم رفض طلب القرض الخاص بك.', - entityType: 'LOAN', - entityId: loan.id, - excludeUserIds: [userId], - }); - return loan; } async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { @@ -1203,30 +1063,7 @@ private isSystemAdminUser(user: any) { }, include: { employee: true }, }); - await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); - - const employeeFullName = `${req.employee.firstName} ${req.employee.lastName}`; - - await notificationsService.notifyApprovalRecipients({ - resource: 'purchase_requests', - type: 'PURCHASE_REQUEST_SUBMITTED', - title: 'طلب شراء جديد بانتظار الموافقة', - message: `قام الموظف ${employeeFullName} بإرسال طلب شراء جديد برقم ${req.requestNumber}.`, - entityType: 'PURCHASE_REQUEST', - entityId: req.id, - excludeUserIds: [userId], - }); - - await notificationsService.notifyEmployeeUser({ - employeeId: req.employeeId, - type: 'PURCHASE_REQUEST_CREATED', - title: 'تم إرسال طلب الشراء', - message: `تم إرسال طلب الشراء الخاص بك برقم ${req.requestNumber} وهو الآن بانتظار المراجعة.`, - entityType: 'PURCHASE_REQUEST', - entityId: req.id, - excludeUserIds: [], - }); - + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); return req; } @@ -1236,19 +1073,8 @@ private isSystemAdminUser(user: any) { data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null }, include: { employee: true }, }); - await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); - - await notificationsService.notifyEmployeeUser({ - employeeId: req.employeeId, - type: 'PURCHASE_REQUEST_APPROVED', - title: 'تمت الموافقة على طلب الشراء', - message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`, - entityType: 'PURCHASE_REQUEST', - entityId: req.id, - excludeUserIds: [userId], - }); - - return req;; + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); + return req; } async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { @@ -1258,19 +1084,6 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); - - await notificationsService.notifyEmployeeUser({ - employeeId: req.employeeId, - type: 'PURCHASE_REQUEST_REJECTED', - title: 'تم رفض طلب الشراء', - message: rejectedReason?.trim() - ? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}` - : `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`, - entityType: 'PURCHASE_REQUEST', - entityId: req.id, - excludeUserIds: [userId], - }); - return req; } diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index 5080f33..ec45537 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -187,12 +187,61 @@ export class PortalController { async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { try { - const data = await portalService.submitExpenseClaim(req.user?.employeeId, req.body, req.user!.id); - res.status(201).json(ResponseFormatter.success(data, 'تم إرسال كشف المصاريف - Expense claim submitted')); - } catch (error) { + const body = { ...req.body }; + + if (typeof body.items === 'string') { + body.items = JSON.parse(body.items); + } + + const data = await portalService.submitExpenseClaim( + req.user?.employeeId, + body, + req.user!.id, + req.file as any + ); + + res + .status(201) + .json( + ResponseFormatter.success( + data, + 'تم إرسال كشف المصاريف - Expense claim submitted' + ) + ); + } catch (error: any) { + if (error.message?.includes('نوع الملف غير مدعوم')) { + return res.status(400).json({ + success: false, + message: error.message, + }); + } + next(error); } } + async viewExpenseClaimAttachment( + req: AuthRequest, + res: Response, + next: NextFunction +) { + try { + const attachment = await portalService.getExpenseClaimAttachmentFile( + req.params.attachmentId + ); + + const encodedFileName = encodeURIComponent(attachment.originalName); + + res.setHeader('Content-Type', attachment.mimeType); + res.setHeader( + 'Content-Disposition', + `inline; filename*=UTF-8''${encodedFileName}` + ); + + res.sendFile(attachment.path); + } catch (error) { + next(error); + } +} async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) { try { @@ -205,13 +254,21 @@ export class PortalController { } async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { - try { - const data = await portalService.approveManagedExpenseClaim(req.user?.employeeId, req.params.id, req.user!.id); - res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved')); - } catch (error) { - next(error); - } + try { + const { approvalNote } = req.body; + + const data = await portalService.approveManagedExpenseClaim( + req.user?.employeeId, + req.params.id, + req.user!.id, + approvalNote + ); + + res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved')); + } catch (error) { + next(error); } +} async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) { try { diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index 0abeb7a..65a608c 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -2,6 +2,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { hrService } from './hr.service'; import { notificationsService } from '../notifications/notifications.service'; +import path from 'path'; class PortalService { private requireEmployeeId(employeeId: string | undefined): string { @@ -242,6 +243,40 @@ class PortalService { return this.formatOvertimeRequest(attendance); } + + private async attachExpenseClaimFiles(claims: T[]) { + const claimIds = claims.map((claim) => claim.id); + + if (claimIds.length === 0) { + return claims.map((claim) => ({ ...claim, attachments: [] })); + } + + const attachments = await prisma.attachment.findMany({ + where: { + entityType: 'EXPENSE_CLAIM', + entityId: { in: claimIds }, + }, + orderBy: { uploadedAt: 'desc' }, + }); + + return claims.map((claim) => ({ + ...claim, + attachments: attachments.filter((attachment) => attachment.entityId === claim.id), + })); +} + +async getExpenseClaimAttachmentFile(attachmentId: string) { + const attachment = await prisma.attachment.findUnique({ + where: { id: attachmentId }, + }); + + if (!attachment || attachment.entityType !== 'EXPENSE_CLAIM') { + throw new AppError(404, 'الملف غير موجود'); + } + + return attachment; +} + async getManagedOvertimeRequests(employeeId: string | undefined) { this.requireEmployeeId(employeeId); @@ -522,39 +557,43 @@ class PortalService { } async getMyExpenseClaims(employeeId: string | undefined) { - const empId = this.requireEmployeeId(employeeId); + const empId = this.requireEmployeeId(employeeId); - return prisma.expenseClaim.findMany({ - where: { employeeId: empId }, - include: { - employee: { - select: { - id: true, - firstName: true, - lastName: true, - uniqueEmployeeId: true, - }, + const claims = await prisma.expenseClaim.findMany({ + where: { employeeId: empId }, + include: { + employee: { + select: { + id: true, + firstName: true, + lastName: true, + uniqueEmployeeId: true, }, }, - orderBy: { createdAt: 'desc' }, - }); - } - - async submitExpenseClaim( - employeeId: string | undefined, - data: { - items?: Array<{ - expenseDate?: string; - amount?: number | string; - entityName?: string; - description?: string; - projectOrTender?: string; - proofRef?: string; - }>; - description?: string; }, - userId: string - ) { + orderBy: { createdAt: 'desc' }, + }); + + return this.attachExpenseClaimFiles(claims); +} + +async submitExpenseClaim( + employeeId: string | undefined, + data: { + items?: Array<{ + expenseDate?: string; + amount?: number | string; + entityName?: string; + description?: string; + projectOrTender?: string; + proofRef?: string; + }>; + description?: string; + }, + userId: string, + file?: Express.Multer.File +) { + const empId = this.requireEmployeeId(employeeId); const items = Array.isArray(data.items) ? data.items : []; @@ -604,6 +643,23 @@ class PortalService { }, }); + if (file) { + const fileName = path.basename(file.path); + + await prisma.attachment.create({ + data: { + entityType: 'EXPENSE_CLAIM', + entityId: claim.id, + fileName, + originalName: (file as any).decodedOriginalName || file.originalname, + mimeType: file.mimetype, + size: file.size, + path: file.path, + category: 'EXPENSE_CLAIM_ATTACHMENT', + uploadedBy: userId, + }, + }); +} const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`; await notificationsService.notifyUsersWithPermission({ @@ -627,20 +683,20 @@ class PortalService { excludeUserIds: [], }); - return claim; +const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]); +return claimWithAttachments; } async getManagedExpenseClaims(employeeId: string | undefined, status?: string) { this.requireEmployeeId(employeeId); const where: any = {}; + if (status && status !== 'all') { where.status = status; - } else { - where.status = 'PENDING'; } - return prisma.expenseClaim.findMany({ + const claims = await prisma.expenseClaim.findMany({ where, include: { employee: { @@ -655,12 +711,14 @@ class PortalService { }, orderBy: { createdAt: 'desc' }, }); - } + return this.attachExpenseClaimFiles(claims); + } async approveManagedExpenseClaim( managerEmployeeId: string | undefined, claimId: string, - userId: string + userId: string, + approvalNote?: string, ) { this.requireEmployeeId(managerEmployeeId); @@ -693,6 +751,7 @@ class PortalService { approvedBy: userId, approvedAt: new Date(), rejectedReason: null, + approvalNote: approvalNote?.trim()?.slice(0, 1000) || null, }, include: { employee: { @@ -705,12 +764,12 @@ class PortalService { }, }, }); - + const note = approvalNote?.trim()?.slice(0, 1000); await notificationsService.notifyEmployeeUser({ employeeId: claim.employeeId, type: 'EXPENSE_CLAIM_APPROVED', title: 'تمت الموافقة على كشف المصاريف', - message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.`, + message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.${note ? ` ملاحظة المعتمِد: ${note}` : ''}`, entityType: 'EXPENSE_CLAIM', entityId: claim.id, excludeUserIds: [userId], diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 91fa62e..cfe3798 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -1,7 +1,6 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; -import { notificationsService } from '../notifications/notifications.service'; import { Prisma } from '@prisma/client'; import path from 'path'; import fs from 'fs' @@ -209,25 +208,11 @@ class TendersService { return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; } - private getComputedTenderStatus(tender: T) { - if (tender.status === 'CONVERTED_TO_DEAL' || tender.status === 'CANCELLED') { - return tender.status; - } - - if (tender.closingDate && new Date(tender.closingDate) < new Date()) { - return 'EXPIRED'; - } - - return tender.status || 'ACTIVE'; - } - - private mapTenderExtraFields(tender: T) { + private mapTenderExtraFields(tender: T) { const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); return { ...tender, - status: this.getComputedTenderStatus(tender), - originalStatus: tender.status, notes: cleanNotes || null, initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), finalBondValue: meta.finalBondValue ?? null, @@ -360,9 +345,7 @@ class TendersService { { issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, ]; } - if (filters.status && filters.status !== 'EXPIRED') { - where.status = filters.status; - } + if (filters.status) where.status = filters.status; if (filters.source) where.source = filters.source; if (filters.announcementType) where.announcementType = filters.announcementType; @@ -378,15 +361,9 @@ class TendersService { }, orderBy: { createdAt: 'desc' }, }); - const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t)); - const filteredTenders = - filters.status === 'EXPIRED' - ? mappedTenders.filter((t: any) => t.status === 'EXPIRED') - : mappedTenders; - - return { - tenders: filteredTenders, - total: filters.status === 'EXPIRED' ? filteredTenders.length : total, + return { + tenders: tenders.map((t) => this.mapTenderExtraFields(t)), + total, page, pageSize, }; @@ -540,20 +517,20 @@ class TendersService { }, }); - const assignedUser = directive.assignedToEmployee?.user; - if (assignedUser?.id) { - const typeLabel = this.getDirectiveTypeLabel(data.type); - - await notificationsService.notifyMany({ - userIds: [assignedUser.id], - type: 'TENDER_DIRECTIVE_ASSIGNED', - title: 'تم إسناد توجيه مناقصة جديد', - message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`, - entityType: 'TENDER', - entityId: tender.id, - excludeUserIds: [userId], - }); - } + const assignedUser = directive.assignedToEmployee?.user; + if (assignedUser?.id) { + const typeLabel = this.getDirectiveTypeLabel(data.type); + await prisma.notification.create({ + data: { + userId: assignedUser.id, + type: 'TENDER_DIRECTIVE_ASSIGNED', + title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`, + message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`, + entityType: 'TENDER_DIRECTIVE', + entityId: directive.id, + }, + }); + } await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', @@ -701,17 +678,7 @@ class TendersService { return deal; } - private decodeUploadedFileName(fileName: string) { - if (!fileName) return 'file'; - - try { - return Buffer.from(fileName, 'latin1').toString('utf8'); - } catch { - return fileName; - } - } - - async uploadTenderAttachment( + async uploadTenderAttachment( tenderId: string, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, @@ -719,17 +686,14 @@ class TendersService { ) { const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); if (!tender) throw new AppError(404, 'Tender not found'); - const fileName = path.basename(file.path); - const originalName = this.decodeUploadedFileName(file.originalname); - const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER', entityId: tenderId, tenderId, fileName, - originalName, + originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, @@ -737,7 +701,6 @@ class TendersService { uploadedBy: userId, }, }); - await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, @@ -745,7 +708,6 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); - return attachment; } @@ -759,12 +721,8 @@ class TendersService { where: { id: directiveId }, select: { id: true, tenderId: true }, }); - if (!directive) throw new AppError(404, 'Directive not found'); - const fileName = path.basename(file.path); - const originalName = this.decodeUploadedFileName(file.originalname); - const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER_DIRECTIVE', @@ -772,7 +730,7 @@ class TendersService { tenderDirectiveId: directiveId, tenderId: directive.tenderId, fileName, - originalName, + originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, @@ -780,7 +738,6 @@ class TendersService { uploadedBy: userId, }, }); - await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, @@ -788,9 +745,9 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); - return attachment; } + async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, @@ -808,10 +765,12 @@ class TendersService { if (!attachment) throw new AppError(404, 'File not found') + // حذف من الديسك if (attachment.path && fs.existsSync(attachment.path)) { fs.unlinkSync(attachment.path) } + // حذف من DB await prisma.attachment.delete({ where: { id: attachmentId }, }) diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index dfc403c..d94ec76 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -9,7 +9,6 @@ import inventoryRoutes from '../modules/inventory/inventory.routes'; import projectsRoutes from '../modules/projects/projects.routes'; import marketingRoutes from '../modules/marketing/marketing.routes'; import tendersRoutes from '../modules/tenders/tenders.routes'; -import notificationsRoutes from '../modules/notifications/notifications.routes'; const router = Router(); @@ -24,7 +23,6 @@ router.use('/inventory', inventoryRoutes); router.use('/projects', projectsRoutes); router.use('/marketing', marketingRoutes); router.use('/tenders', tendersRoutes); -router.use('/notifications', notificationsRoutes); // API info router.get('/', (req, res) => { diff --git a/frontend/src/app/portal/expense-claims/page.tsx b/frontend/src/app/portal/expense-claims/page.tsx index 04acbc1..6bcd249 100644 --- a/frontend/src/app/portal/expense-claims/page.tsx +++ b/frontend/src/app/portal/expense-claims/page.tsx @@ -17,6 +17,7 @@ type ExpenseClaimLine = { type ExpenseClaimFormState = { items: ExpenseClaimLine[]; description: string; + attachment: File | null; }; const emptyLine = (): ExpenseClaimLine => ({ @@ -31,6 +32,7 @@ const emptyLine = (): ExpenseClaimLine => ({ const initialForm: ExpenseClaimFormState = { items: [emptyLine()], description: '', + attachment: null, }; function getStatusLabel(status: string) { @@ -74,10 +76,11 @@ export default function PortalExpenseClaimsPage() { const [error, setError] = useState(null); const [form, setForm] = useState(initialForm); - const pendingCount = useMemo( - () => claims.filter((c) => c.status === 'PENDING').length, - [claims] - ); + const [statusFilter, setStatusFilter] = useState('all'); + const filteredClaims = useMemo(() => { + if (statusFilter === 'all') return claims; + return claims.filter((claim) => claim.status === statusFilter); + }, [claims, statusFilter]); const totalAmount = useMemo(() => { return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0); @@ -127,6 +130,24 @@ export default function PortalExpenseClaimsPage() { })); }; + + async function openAttachment(attachment: any) { + try { + const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id); + const blobUrl = window.URL.createObjectURL( + new Blob([blob], { type: attachment.mimeType }) + ); + + window.open(blobUrl, '_blank'); + + setTimeout(() => { + window.URL.revokeObjectURL(blobUrl); + }, 10000); + } catch (error) { + alert('تعذر فتح المرفق'); + } +} + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -157,6 +178,8 @@ export default function PortalExpenseClaimsPage() { await portalAPI.submitExpenseClaim({ items, description: form.description.trim() || undefined, + attachment: form.attachment, + }); setForm(initialForm); @@ -198,12 +221,16 @@ export default function PortalExpenseClaimsPage() { -
-
قيد المراجعة
-
- {pendingCount} -
-
+
آخر تحديث
@@ -222,14 +249,14 @@ export default function PortalExpenseClaimsPage() {
جاري التحميل...
) : error ? (
{error}
- ) : claims.length === 0 ? ( -
+ ) : filteredClaims.length === 0 ? ( +
لا توجد طلبات كشف مصاريف حالياً
) : (
- {claims.map((claim) => { - const isSelected = claim.id === claimId; + {filteredClaims.map((claim) => { + const isSelected = claim.id === claimId; return (
+ {getStatusLabel(claim.status)}
+ {claim.status === 'APPROVED' && claim.approvalNote ? ( +
+ ملاحظة المعتمِد: {claim.approvalNote} +
+ ) : null}
@@ -333,6 +366,24 @@ export default function PortalExpenseClaimsPage() { {' '} {item.proofRef || '-'}
+ {claim.attachments && claim.attachments.length > 0 ? ( +
+
المرفقات:
+ +
+ {claim.attachments.map((attachment) => ( + + ))} +
+
+ ) : null}
البيان:{' '} {item.description || '-'} @@ -510,6 +561,36 @@ export default function PortalExpenseClaimsPage() { placeholder="أي ملاحظات عامة على الكشف" />
+
+ + + + setForm((prev) => ({ + ...prev, + attachment: e.target.files?.[0] || null, + })) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + {form.attachment ? ( +
+ {form.attachment.name} + +
+ ) : null} +
الإجمالي: {totalAmount.toLocaleString()} diff --git a/frontend/src/app/portal/leave/page.tsx b/frontend/src/app/portal/leave/page.tsx index 00e301b..6399339 100644 --- a/frontend/src/app/portal/leave/page.tsx +++ b/frontend/src/app/portal/leave/page.tsx @@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner' import { toast } from 'react-hot-toast' import { Plus } from 'lucide-react' +const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => { + const hour = Math.floor(i / 2).toString().padStart(2, '0') + const minute = i % 2 === 0 ? '00' : '30' + return `${hour}:${minute}` +}) + const LEAVE_TYPES = [ { value: 'ANNUAL', label: 'إجازة سنوية' }, { value: 'HOURLY', label: 'إجازة ساعية' }, @@ -47,6 +53,9 @@ export default function PortalLeavePage() { } useEffect(() => load(), []) + const toCompanyDateTime = (date: string, time: string) => { + return `${date}T${time}:00+03:00` + } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -80,8 +89,8 @@ export default function PortalLeavePage() { return } - payload.startDate = `${form.leaveDate}T${form.startTime}:00` - payload.endDate = `${form.leaveDate}T${form.endTime}:00` + payload.startDate = `${form.leaveDate}T${form.startTime}:00+03:00` + payload.endDate = `${form.leaveDate}T${form.endTime}:00+03:00` } setSubmitting(true) @@ -238,22 +247,30 @@ export default function PortalLeavePage() {
- setForm(p => ({ ...p, startTime: e.target.value }))} className="border p-2 rounded w-full" - /> + > + + {TIME_OPTIONS.map((time) => ( + + ))} +
- setForm(p => ({ ...p, endTime: e.target.value }))} className="border p-2 rounded w-full" - /> + > + + {TIME_OPTIONS.map((time) => ( + + ))} +
)} diff --git a/frontend/src/app/portal/managed-expense-claims/page.tsx b/frontend/src/app/portal/managed-expense-claims/page.tsx index 6e879e1..e0d70ce 100644 --- a/frontend/src/app/portal/managed-expense-claims/page.tsx +++ b/frontend/src/app/portal/managed-expense-claims/page.tsx @@ -69,13 +69,33 @@ export default function ManagedExpenseClaimsPage() { loadClaims(statusFilter); }, [statusFilter]); + async function openAttachment(attachment: any) { + try { + const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id); + const blobUrl = window.URL.createObjectURL( + new Blob([blob], { type: attachment.mimeType }) + ); + + window.open(blobUrl, '_blank'); + + setTimeout(() => { + window.URL.revokeObjectURL(blobUrl); + }, 10000); + } catch (error) { + alert('تعذر فتح المرفق'); + } +} + async function handleApprove(id: string) { - const confirmed = window.confirm('هل أنت متأكد من الموافقة على طلب كشف المصاريف؟'); - if (!confirmed) return; + const note = window.prompt( + 'ملاحظة مع الموافقة (اتركها فارغة إذا لا توجد):', + '', + ); + if (note === null) return; try { setSubmittingId(id); - await portalAPI.approveManagedExpenseClaim(id); + await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined); await loadClaims(statusFilter); } catch (error: any) { alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة'); @@ -309,6 +329,27 @@ export default function ManagedExpenseClaimsPage() {
)} + + + {claim.attachments && claim.attachments.length > 0 ? ( +
+
المرفقات:
+ +
+ {claim.attachments.map((attachment) => ( + + {attachment.originalName} + + ))} +
+
+ ) : null} {claim.status === 'REJECTED' && claim.rejectedReason ? (
@@ -317,6 +358,13 @@ export default function ManagedExpenseClaimsPage() {
) : null} + {claim.status === 'APPROVED' && claim.approvalNote ? ( +
+ ملاحظة المعتمِد:{' '} + {claim.approvalNote} +
+ ) : null} + {claim.status === 'PENDING' ? (