From 12c4ca833494c11d26d214e81d58e0b109cb8152 Mon Sep 17 00:00:00 2001 From: Aya Date: Tue, 19 May 2026 11:41:44 +0300 Subject: [PATCH] edits for trenders attachments & claims --- backend/Dockerfile | 4 +- .../add-expense-mark-paid-permission.sql | 22 +++ .../migration.sql | 0 backend/prisma/schema.prisma | 2 + backend/src/config/index.ts | 7 +- backend/src/modules/hr/hr.routes.ts | 26 ++- backend/src/modules/hr/portal.controller.ts | 29 +++- backend/src/modules/hr/portal.service.ts | 156 +++++++++++++++--- .../src/modules/tenders/tenders.controller.ts | 8 +- .../src/modules/tenders/tenders.service.ts | 108 ++++++++++-- docker-compose.yml | 4 +- .../src/app/admin/permission-groups/page.tsx | 1 + frontend/src/app/admin/roles/page.tsx | 1 + .../src/app/portal/expense-claims/page.tsx | 88 +++++++--- .../portal/managed-expense-claims/page.tsx | 135 +++++++++++++-- frontend/src/app/tenders/[id]/page.tsx | 63 +++++-- frontend/src/contexts/AuthContext.tsx | 9 +- frontend/src/lib/api/portal.ts | 19 ++- frontend/src/lib/api/tenders.ts | 6 +- 19 files changed, 583 insertions(+), 105 deletions(-) create mode 100644 backend/prisma/add-expense-mark-paid-permission.sql rename backend/prisma/migrations/{20250311000000_add_tender_management => 20270223105733_add_tender_management}/migration.sql (100%) diff --git a/backend/Dockerfile b/backend/Dockerfile index fd834da..fde0c8c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -56,8 +56,8 @@ RUN npm ci --only=production && \ # Copy built application COPY --from=builder /app/dist ./dist -# Change ownership of all files to the nodejs user -RUN chown -R expressjs:nodejs /app +# Ensure uploads directory exists and is owned by app user +RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app # Switch to non-root user USER expressjs diff --git a/backend/prisma/add-expense-mark-paid-permission.sql b/backend/prisma/add-expense-mark-paid-permission.sql new file mode 100644 index 0000000..aae8336 --- /dev/null +++ b/backend/prisma/add-expense-mark-paid-permission.sql @@ -0,0 +1,22 @@ +-- Add 'mark-as-paid' action to department_expense_claims permission for positions +-- that already have the 'approve' action on it. +-- This is intended for rolling out the new "mark expense claim as paid" feature +-- to managers/accountants who currently approve claims, without granting it to everyone. +-- +-- Safe to run multiple times: only updates rows that don't already have the action. +-- +-- Run on server: +-- docker-compose exec -T postgres psql -U postgres -d mind14_crm -f - < backend/prisma/add-expense-mark-paid-permission.sql +-- Or from backend: +-- npx prisma db execute --file prisma/add-expense-mark-paid-permission.sql + +UPDATE position_permissions +SET + actions = actions || '["mark-as-paid"]'::jsonb, + "updatedAt" = NOW() +WHERE module = 'department_expense_claims' + AND resource = '*' + AND NOT (actions @> '["mark-as-paid"]'::jsonb) + AND NOT (actions @> '["*"]'::jsonb) + AND NOT (actions @> '["all"]'::jsonb) + AND actions @> '["approve"]'::jsonb; diff --git a/backend/prisma/migrations/20250311000000_add_tender_management/migration.sql b/backend/prisma/migrations/20270223105733_add_tender_management/migration.sql similarity index 100% rename from backend/prisma/migrations/20250311000000_add_tender_management/migration.sql rename to backend/prisma/migrations/20270223105733_add_tender_management/migration.sql diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f8a42b4..ba768a6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -525,6 +525,8 @@ model ExpenseClaim { rejectedReason String? approvalNote String? + isPaid Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index db920a2..e13d703 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,4 +1,5 @@ import dotenv from 'dotenv'; +import path from 'path'; dotenv.config(); @@ -33,10 +34,10 @@ export const config = { }, upload: { - maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB - path: process.env.UPLOAD_PATH || './uploads', + maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10), + path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'), }, - + pagination: { defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10), maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10), diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 8c2bb59..9d2dd79 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -18,6 +18,21 @@ if (!fs.existsSync(expenseClaimsUploadDir)) { const expenseClaimStorage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir), filename: (_req, file, cb) => { + // Browsers send filenames in multipart/form-data as raw UTF-8 bytes, + // but multer/busboy decode them as latin1 by default. For Arabic + // (or any non-ASCII) filenames this produces mojibake like "ÙÙŠÙØ§...". + // Reverse the misinterpretation: take the latin1 string back to bytes, + // then decode as UTF-8. The service reads `decodedOriginalName` when + // it persists the attachment to the DB. + try { + (file as any).decodedOriginalName = Buffer.from( + file.originalname, + 'latin1' + ).toString('utf8'); + } catch { + (file as any).decodedOriginalName = file.originalname; + } + const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_'); cb(null, `${crypto.randomUUID()}-${safeName}`); }, @@ -105,11 +120,12 @@ router.get('/portal/expense-claims', portalController.getMyExpenseClaims); router.post( '/portal/expense-claims', (req, res, next) => { - expenseClaimUpload.single('attachment')(req, res, (error: any) => { + // Accept up to 10 files under the form field "attachments". + expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => { if (error) { return res.status(400).json({ success: false, - message: error.message || 'تعذر رفع المرفق', + message: error.message || 'تعذر رفع المرفقات', }); } @@ -136,6 +152,12 @@ router.post( portalController.rejectManagedExpenseClaim ); +router.patch( + '/portal/managed-expense-claims/:id/paid', + authorize('department_expense_claims', '*', 'mark-as-paid'), + portalController.markExpenseClaimPaid +); + // ========== EMPLOYEES ========== router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index 7a136a3..ad2c68c 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -239,11 +239,13 @@ export class PortalController { body.items = JSON.parse(body.items); } + const files = (req.files as Express.Multer.File[] | undefined) || []; + const data = await portalService.submitExpenseClaim( req.user?.employeeId, body, req.user!.id, - req.file as any + files ); res @@ -292,7 +294,8 @@ export class PortalController { async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) { try { const status = req.query.status as string | undefined; - const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status); + const search = req.query.search as string | undefined; + const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status, search); res.json(ResponseFormatter.success(data)); } catch (error) { next(error); @@ -331,6 +334,28 @@ export class PortalController { } } + async markExpenseClaimPaid(req: AuthRequest, res: Response, next: NextFunction) { + try { + const isPaid = Boolean(req.body?.isPaid); + const data = await portalService.markExpenseClaimPaid( + req.user?.employeeId, + req.params.id, + isPaid, + req.user!.id + ); + res.json( + ResponseFormatter.success( + data, + isPaid + ? 'تم تعليم كشف المصاريف كمقبوض - Expense claim marked as paid' + : 'تم إلغاء تعليم القبض - Paid mark removed' + ) + ); + } catch (error) { + next(error); + } + } + async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) { try { const month = req.query.month ? parseInt(req.query.month as string) : undefined; diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts index 65a608c..e6ca370 100644 --- a/backend/src/modules/hr/portal.service.ts +++ b/backend/src/modules/hr/portal.service.ts @@ -4,6 +4,32 @@ import { hrService } from './hr.service'; import { notificationsService } from '../notifications/notifications.service'; import path from 'path'; +// 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 +// byte in 0xC2-0xDF followed by a continuation byte in 0x80-0xBF, which is +// exactly what we look for here. Properly-stored Arabic text has code +// points U+0600-U+06FF and won't match this pattern, so the check is safe. +const MOJIBAKE_PATTERN = /[\u00C2-\u00DF][\u0080-\u00BF]/; + +/** + * Heuristically repair a string that was stored after multer interpreted + * the file's UTF-8 filename bytes as latin1. Returns the input unchanged + * if it doesn't look like mojibake or if re-decoding would lose data. + */ +function repairFilenameEncoding(value: string | null | undefined): string { + if (!value) return value ?? ''; + if (!MOJIBAKE_PATTERN.test(value)) return value; + try { + const decoded = Buffer.from(value, 'latin1').toString('utf8'); + // If the re-decode produced replacement chars, the original wasn't + // actually mojibake — bail out and keep the existing value. + if (decoded.includes('\uFFFD')) return value; + return decoded; + } catch { + return value; + } +} + class PortalService { private requireEmployeeId(employeeId: string | undefined): string { if (!employeeId) { @@ -261,7 +287,13 @@ class PortalService { return claims.map((claim) => ({ ...claim, - attachments: attachments.filter((attachment) => attachment.entityId === claim.id), + attachments: attachments + .filter((attachment) => attachment.entityId === claim.id) + .map((attachment) => ({ + ...attachment, + // Repair mojibake in records uploaded before the multer fix. + originalName: repairFilenameEncoding(attachment.originalName), + })), })); } @@ -274,7 +306,12 @@ async getExpenseClaimAttachmentFile(attachmentId: string) { throw new AppError(404, 'الملف غير موجود'); } - return attachment; + // Repair mojibake so the Content-Disposition filename* the controller + // generates uses the real Arabic name when opening/downloading. + return { + ...attachment, + originalName: repairFilenameEncoding(attachment.originalName), + }; } async getManagedOvertimeRequests(employeeId: string | undefined) { @@ -591,7 +628,7 @@ async submitExpenseClaim( description?: string; }, userId: string, - file?: Express.Multer.File + files?: Express.Multer.File[] ) { const empId = this.requireEmployeeId(employeeId); @@ -643,22 +680,24 @@ async submitExpenseClaim( }, }); - 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, - }, - }); + if (files && files.length > 0) { + await Promise.all( + files.map((file) => + prisma.attachment.create({ + data: { + entityType: 'EXPENSE_CLAIM', + entityId: claim.id, + 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 employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`; @@ -687,7 +726,11 @@ const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]); return claimWithAttachments; } - async getManagedExpenseClaims(employeeId: string | undefined, status?: string) { + async getManagedExpenseClaims( + employeeId: string | undefined, + status?: string, + search?: string, + ) { this.requireEmployeeId(employeeId); const where: any = {}; @@ -696,6 +739,19 @@ return claimWithAttachments; where.status = status; } + const trimmedSearch = search?.trim(); + if (trimmedSearch) { + where.employee = { + OR: [ + { firstName: { contains: trimmedSearch, mode: 'insensitive' } }, + { lastName: { contains: trimmedSearch, mode: 'insensitive' } }, + { firstNameAr: { contains: trimmedSearch, mode: 'insensitive' } }, + { lastNameAr: { contains: trimmedSearch, mode: 'insensitive' } }, + { uniqueEmployeeId: { contains: trimmedSearch, mode: 'insensitive' } }, + ], + }; + } + const claims = await prisma.expenseClaim.findMany({ where, include: { @@ -843,6 +899,66 @@ return claimWithAttachments; return claim; } + async markExpenseClaimPaid( + managerEmployeeId: string | undefined, + claimId: string, + isPaid: boolean, + userId: string + ) { + this.requireEmployeeId(managerEmployeeId); + + const existing = await prisma.expenseClaim.findUnique({ + where: { id: claimId }, + select: { id: true, status: true, isPaid: true, claimNumber: true }, + }); + + if (!existing) { + throw new AppError(404, 'كشف المصاريف غير موجود - Expense claim not found'); + } + + if (existing.status !== 'APPROVED') { + throw new AppError( + 400, + 'يمكن تعليم القبض فقط على الكشوف المعتمدة - Only approved claims can be marked as paid' + ); + } + + if (existing.isPaid === isPaid) { + // Idempotent: no change needed. + const claim = await prisma.expenseClaim.findUnique({ + where: { id: claimId }, + include: { + employee: { + select: { + id: true, + firstName: true, + lastName: true, + uniqueEmployeeId: true, + }, + }, + }, + }); + return claim; + } + + const claim = await prisma.expenseClaim.update({ + where: { id: claimId }, + data: { isPaid }, + include: { + employee: { + select: { + id: true, + firstName: true, + lastName: true, + uniqueEmployeeId: true, + }, + }, + }, + }); + + return claim; + } + async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) { const empId = this.requireEmployeeId(employeeId); const now = new Date(); diff --git a/backend/src/modules/tenders/tenders.controller.ts b/backend/src/modules/tenders/tenders.controller.ts index 60993b5..2c7e645 100644 --- a/backend/src/modules/tenders/tenders.controller.ts +++ b/backend/src/modules/tenders/tenders.controller.ts @@ -250,8 +250,12 @@ export class TendersController { const fs = require('fs') if (!fs.existsSync(file)) { + console.error('[tenders.viewAttachment] Resolved path missing at send time', { + attachmentId: req.params.attachmentId, + resolvedPath: file, + }) return res.status(404).json( - ResponseFormatter.error('File not found', 'الملف غير موجود') + ResponseFormatter.error('File not found - الملف غير موجود', 'FILE_NOT_FOUND') ) } @@ -259,7 +263,7 @@ export class TendersController { return res.sendFile(path.resolve(file)) } catch (error) { - console.error(error) + console.error('[tenders.viewAttachment]', error) next(error) } } diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 7dc3dc8..c2564d8 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger'; import { Prisma } from '@prisma/client'; import path from 'path'; import fs from 'fs' + import { config } from '../../config'; const TENDER_SOURCE_VALUES = [ @@ -747,7 +748,27 @@ private getEffectiveTenderStatus(tender: { ) { 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 absolutePath = path.resolve(file.path); + const fileName = path.basename(absolutePath); + + // Verify multer actually wrote the file to disk before recording it. + if (!fs.existsSync(absolutePath)) { + console.error('[tenders.uploadTenderAttachment] Multer reported a file but it does not exist on disk', { + tenderId, + multerPath: file.path, + resolvedPath: absolutePath, + size: file.size, + }); + throw new AppError(500, 'File upload failed - فشل رفع الملف'); + } + + console.log('[tenders.uploadTenderAttachment] File saved', { + tenderId, + path: absolutePath, + size: file.size, + }); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER', @@ -757,11 +778,12 @@ private getEffectiveTenderStatus(tender: { originalName: file.originalname, mimeType: file.mimetype, size: file.size, - path: file.path, + path: absolutePath, category: category || 'ANNOUNCEMENT', uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, @@ -769,6 +791,7 @@ private getEffectiveTenderStatus(tender: { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } @@ -782,8 +805,29 @@ private getEffectiveTenderStatus(tender: { where: { id: directiveId }, select: { id: true, tenderId: true }, }); + if (!directive) throw new AppError(404, 'Directive not found'); - const fileName = path.basename(file.path); + + const absolutePath = path.resolve(file.path); + const fileName = path.basename(absolutePath); + + // Verify multer actually wrote the file to disk before recording it. + if (!fs.existsSync(absolutePath)) { + console.error('[tenders.uploadDirectiveAttachment] Multer reported a file but it does not exist on disk', { + directiveId, + multerPath: file.path, + resolvedPath: absolutePath, + size: file.size, + }); + throw new AppError(500, 'File upload failed - فشل رفع الملف'); + } + + console.log('[tenders.uploadDirectiveAttachment] File saved', { + directiveId, + path: absolutePath, + size: file.size, + }); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER_DIRECTIVE', @@ -794,11 +838,12 @@ private getEffectiveTenderStatus(tender: { originalName: file.originalname, mimeType: file.mimetype, size: file.size, - path: file.path, + path: absolutePath, category: category || 'TASK_FILE', uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, @@ -806,17 +851,62 @@ private getEffectiveTenderStatus(tender: { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } - async getAttachmentFile(attachmentId: string): Promise { + async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, - }) + }); - if (!attachment) throw new AppError(404, 'File not found') + if (!attachment) throw new AppError(404, 'File not found'); - return attachment.path + // Try multiple candidate locations for the file (in order of preference). + // This makes the system resilient to path changes between deploys (e.g. + // when an old DB row has a stale absolute path). + const candidates = [ + attachment.path, + path.join(config.upload.path, 'tenders', attachment.fileName), + path.join(config.upload.path, attachment.fileName), + path.join(process.cwd(), 'uploads', 'tenders', attachment.fileName), + ] + .filter(Boolean) + .map((p) => path.resolve(String(p))); + + const existingPath = candidates.find((p) => fs.existsSync(p)); + + if (!existingPath) { + console.error('[tenders.getAttachmentFile] File not found on disk', { + attachmentId, + storedPath: attachment.path, + fileName: attachment.fileName, + uploadConfigPath: config.upload.path, + triedCandidates: candidates, + }); + throw new AppError(404, 'File not found - الملف غير موجود'); + } + + // Self-healing: if the file lives at a path other than what's stored, + // update the DB so future lookups are direct. + if (existingPath !== attachment.path) { + console.warn('[tenders.getAttachmentFile] Stored path was stale, updating', { + attachmentId, + oldPath: attachment.path, + newPath: existingPath, + }); + try { + await prisma.attachment.update({ + where: { id: attachmentId }, + data: { path: existingPath }, + }); + } catch (err) { + // Non-fatal: we still have the resolved path to serve from. + console.error('[tenders.getAttachmentFile] Failed to update stale path', err); + } + } + + return existingPath; } async deleteAttachment(attachmentId: string): Promise { @@ -826,12 +916,10 @@ private getEffectiveTenderStatus(tender: { 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/docker-compose.yml b/docker-compose.yml index 5dd8b7f..d234e77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: BCRYPT_ROUNDS: 10 CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000 volumes: - - ./uploads:/app/uploads + - backend_uploads:/app/uploads depends_on: postgres: condition: service_healthy @@ -71,3 +71,5 @@ services: volumes: postgres_data: driver: local + backend_uploads: + driver: local diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx index 12c4d40..b4a2256 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -38,6 +38,7 @@ const ACTIONS = [ { id: 'delete', name: 'حذف' }, { id: 'export', name: 'تصدير' }, { id: 'approve', name: 'اعتماد' }, + { id: 'mark-as-paid', name: 'تأكيد القبض' }, { id: 'notify', name: 'إشعار' }, { id: 'merge', name: 'دمج' }, ]; diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index 2c28530..10ab449 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -39,6 +39,7 @@ const ACTIONS = [ { id: 'delete', name: 'حذف' }, { id: 'export', name: 'تصدير' }, { id: 'approve', name: 'اعتماد' }, + { id: 'mark-as-paid', name: 'تأكيد القبض' }, { id: 'notify', name: 'إشعار' }, { id: 'merge', name: 'دمج' }, ]; diff --git a/frontend/src/app/portal/expense-claims/page.tsx b/frontend/src/app/portal/expense-claims/page.tsx index 6bcd249..d313fff 100644 --- a/frontend/src/app/portal/expense-claims/page.tsx +++ b/frontend/src/app/portal/expense-claims/page.tsx @@ -17,7 +17,7 @@ type ExpenseClaimLine = { type ExpenseClaimFormState = { items: ExpenseClaimLine[]; description: string; - attachment: File | null; + attachments: File[]; }; const emptyLine = (): ExpenseClaimLine => ({ @@ -32,7 +32,7 @@ const emptyLine = (): ExpenseClaimLine => ({ const initialForm: ExpenseClaimFormState = { items: [emptyLine()], description: '', - attachment: null, + attachments: [], }; function getStatusLabel(status: string) { @@ -178,7 +178,7 @@ export default function PortalExpenseClaimsPage() { await portalAPI.submitExpenseClaim({ items, description: form.description.trim() || undefined, - attachment: form.attachment, + attachments: form.attachments, }); @@ -286,6 +286,27 @@ export default function PortalExpenseClaimsPage() { ) : null} + {claim.status === 'APPROVED' ? ( +
+ + + {claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'} + +
+ ) : null} +
@@ -563,31 +584,58 @@ export default function PortalExpenseClaimsPage() {
- setForm((prev) => ({ - ...prev, - attachment: e.target.files?.[0] || null, - })) - } + onChange={(e) => { + const picked = Array.from(e.target.files || []); + if (picked.length === 0) return; + + setForm((prev) => { + const combined = [...prev.attachments, ...picked]; + if (combined.length > 10) { + alert('يمكن إرفاق 10 ملفات كحد أقصى'); + return { ...prev, attachments: combined.slice(0, 10) }; + } + return { ...prev, attachments: combined }; + }); + + // Reset the input so picking the same file again still fires onChange. + e.target.value = ''; + }} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" /> - {form.attachment ? ( -
- {form.attachment.name} - +

+ يمكن اختيار أكثر من ملف (حتى 10 ملفات). +

+ + {form.attachments.length > 0 ? ( +
+ {form.attachments.map((file, idx) => ( +
+ {file.name} + +
+ ))}
) : null}
diff --git a/frontend/src/app/portal/managed-expense-claims/page.tsx b/frontend/src/app/portal/managed-expense-claims/page.tsx index e0d70ce..aee38e3 100644 --- a/frontend/src/app/portal/managed-expense-claims/page.tsx +++ b/frontend/src/app/portal/managed-expense-claims/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { portalAPI, type ExpenseClaim } from '@/lib/api/portal'; import Modal from '@/components/Modal'; +import { useAuth } from '@/contexts/AuthContext'; function formatDate(value?: string | null) { if (!value) return '-'; @@ -39,10 +40,15 @@ function getStatusClasses(status: string) { } export default function ManagedExpenseClaimsPage() { + const { hasPermission } = useAuth(); + const canMarkAsPaid = hasPermission('department_expense_claims', 'mark-as-paid'); + const [claims, setClaims] = useState([]); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState('PENDING'); + const [searchQuery, setSearchQuery] = useState(''); const [submittingId, setSubmittingId] = useState(null); + const [payingId, setPayingId] = useState(null); const [rejectModalOpen, setRejectModalOpen] = useState(false); const [selectedClaim, setSelectedClaim] = useState(null); @@ -51,11 +57,12 @@ export default function ManagedExpenseClaimsPage() { const searchParams = useSearchParams(); const claimId = searchParams.get('claimId'); - async function loadClaims(status = statusFilter) { + async function loadClaims(status = statusFilter, search = searchQuery) { try { setLoading(true); const data = await portalAPI.getManagedExpenseClaims( - status === 'all' ? undefined : status + status === 'all' ? undefined : status, + search.trim() || undefined, ); setClaims(data); } catch (error: any) { @@ -66,8 +73,13 @@ export default function ManagedExpenseClaimsPage() { } useEffect(() => { - loadClaims(statusFilter); - }, [statusFilter]); + // Debounce the search so we don't fire a request on every keystroke. + const handle = setTimeout(() => { + loadClaims(statusFilter, searchQuery); + }, 400); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter, searchQuery]); async function openAttachment(attachment: any) { try { @@ -96,7 +108,7 @@ export default function ManagedExpenseClaimsPage() { try { setSubmittingId(id); await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined); - await loadClaims(statusFilter); + await loadClaims(statusFilter, searchQuery); } catch (error: any) { alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة'); } finally { @@ -129,7 +141,7 @@ export default function ManagedExpenseClaimsPage() { setRejectModalOpen(false); setSelectedClaim(null); setRejectReason(''); - await loadClaims(statusFilter); + await loadClaims(statusFilter, searchQuery); } catch (error: any) { alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض'); } finally { @@ -137,6 +149,37 @@ export default function ManagedExpenseClaimsPage() { } } + async function handleTogglePaid(claim: ExpenseClaim, nextValue: boolean) { + if (!canMarkAsPaid) return; + if (payingId) return; + + const previousValue = Boolean(claim.isPaid); + + // Optimistic update for snappy UX. + setClaims((prev) => + prev.map((c) => (c.id === claim.id ? { ...c, isPaid: nextValue } : c)) + ); + setPayingId(claim.id); + + try { + const updated = await portalAPI.markExpenseClaimPaid(claim.id, nextValue); + // Sync local state with server response (in case server normalized anything). + setClaims((prev) => + prev.map((c) => + c.id === claim.id ? { ...c, isPaid: Boolean(updated?.isPaid) } : c + ) + ); + } catch (error: any) { + // Rollback on error. + setClaims((prev) => + prev.map((c) => (c.id === claim.id ? { ...c, isPaid: previousValue } : c)) + ); + alert(error?.response?.data?.message || 'تعذر تحديث حالة القبض'); + } finally { + setPayingId(null); + } + } + return (
@@ -149,18 +192,43 @@ export default function ManagedExpenseClaimsPage() {

-
- - +
+
+ +
+ setSearchQuery(e.target.value)} + placeholder="الاسم أو الرقم الوظيفي" + className="w-64 rounded-lg border border-gray-300 px-3 py-2 pe-8 text-sm" + /> + {searchQuery && ( + + )} +
+
+ +
+ + +
@@ -365,6 +433,37 @@ export default function ManagedExpenseClaimsPage() {
) : null} + {claim.status === 'APPROVED' ? ( +
+ handleTogglePaid(claim, e.target.checked)} + className="h-4 w-4 cursor-pointer accent-emerald-600 disabled:cursor-not-allowed" + /> + + {payingId === claim.id && ( + جارٍ الحفظ... + )} + {!canMarkAsPaid && ( + (للعرض فقط) + )} +
+ ) : null} + {claim.status === 'PENDING' ? (