edits for trenders attachments & claims
This commit is contained in:
@@ -56,8 +56,8 @@ RUN npm ci --only=production && \
|
|||||||
# Copy built application
|
# Copy built application
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Change ownership of all files to the nodejs user
|
# Ensure uploads directory exists and is owned by app user
|
||||||
RUN chown -R expressjs:nodejs /app
|
RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER expressjs
|
USER expressjs
|
||||||
|
|||||||
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal file
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal file
@@ -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;
|
||||||
@@ -525,6 +525,8 @@ model ExpenseClaim {
|
|||||||
rejectedReason String?
|
rejectedReason String?
|
||||||
approvalNote String?
|
approvalNote String?
|
||||||
|
|
||||||
|
isPaid Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -33,10 +34,10 @@ export const config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
upload: {
|
upload: {
|
||||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10),
|
||||||
path: process.env.UPLOAD_PATH || './uploads',
|
path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'),
|
||||||
},
|
},
|
||||||
|
|
||||||
pagination: {
|
pagination: {
|
||||||
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
||||||
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ if (!fs.existsSync(expenseClaimsUploadDir)) {
|
|||||||
const expenseClaimStorage = multer.diskStorage({
|
const expenseClaimStorage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
|
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
|
||||||
filename: (_req, file, cb) => {
|
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, '_');
|
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||||
},
|
},
|
||||||
@@ -105,11 +120,12 @@ router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
|
|||||||
router.post(
|
router.post(
|
||||||
'/portal/expense-claims',
|
'/portal/expense-claims',
|
||||||
(req, res, next) => {
|
(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) {
|
if (error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || 'تعذر رفع المرفق',
|
message: error.message || 'تعذر رفع المرفقات',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +152,12 @@ router.post(
|
|||||||
portalController.rejectManagedExpenseClaim
|
portalController.rejectManagedExpenseClaim
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/portal/managed-expense-claims/:id/paid',
|
||||||
|
authorize('department_expense_claims', '*', 'mark-as-paid'),
|
||||||
|
portalController.markExpenseClaimPaid
|
||||||
|
);
|
||||||
|
|
||||||
// ========== EMPLOYEES ==========
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||||
|
|||||||
@@ -239,11 +239,13 @@ export class PortalController {
|
|||||||
body.items = JSON.parse(body.items);
|
body.items = JSON.parse(body.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const files = (req.files as Express.Multer.File[] | undefined) || [];
|
||||||
|
|
||||||
const data = await portalService.submitExpenseClaim(
|
const data = await portalService.submitExpenseClaim(
|
||||||
req.user?.employeeId,
|
req.user?.employeeId,
|
||||||
body,
|
body,
|
||||||
req.user!.id,
|
req.user!.id,
|
||||||
req.file as any
|
files
|
||||||
);
|
);
|
||||||
|
|
||||||
res
|
res
|
||||||
@@ -292,7 +294,8 @@ export class PortalController {
|
|||||||
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined;
|
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));
|
res.json(ResponseFormatter.success(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ import { hrService } from './hr.service';
|
|||||||
import { notificationsService } from '../notifications/notifications.service';
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
// 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 {
|
class PortalService {
|
||||||
private requireEmployeeId(employeeId: string | undefined): string {
|
private requireEmployeeId(employeeId: string | undefined): string {
|
||||||
if (!employeeId) {
|
if (!employeeId) {
|
||||||
@@ -261,7 +287,13 @@ class PortalService {
|
|||||||
|
|
||||||
return claims.map((claim) => ({
|
return claims.map((claim) => ({
|
||||||
...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, 'الملف غير موجود');
|
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) {
|
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||||
@@ -591,7 +628,7 @@ async submitExpenseClaim(
|
|||||||
description?: string;
|
description?: string;
|
||||||
},
|
},
|
||||||
userId: string,
|
userId: string,
|
||||||
file?: Express.Multer.File
|
files?: Express.Multer.File[]
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
@@ -643,22 +680,24 @@ async submitExpenseClaim(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (file) {
|
if (files && files.length > 0) {
|
||||||
const fileName = path.basename(file.path);
|
await Promise.all(
|
||||||
|
files.map((file) =>
|
||||||
await prisma.attachment.create({
|
prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'EXPENSE_CLAIM',
|
entityType: 'EXPENSE_CLAIM',
|
||||||
entityId: claim.id,
|
entityId: claim.id,
|
||||||
fileName,
|
fileName: path.basename(file.path),
|
||||||
originalName: (file as any).decodedOriginalName || file.originalname,
|
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
|
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
|
||||||
|
|
||||||
@@ -687,7 +726,11 @@ const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]);
|
|||||||
return claimWithAttachments;
|
return claimWithAttachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
|
async getManagedExpenseClaims(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
status?: string,
|
||||||
|
search?: string,
|
||||||
|
) {
|
||||||
this.requireEmployeeId(employeeId);
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -696,6 +739,19 @@ return claimWithAttachments;
|
|||||||
where.status = status;
|
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({
|
const claims = await prisma.expenseClaim.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -843,6 +899,66 @@ return claimWithAttachments;
|
|||||||
return claim;
|
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) {
|
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -250,8 +250,12 @@ export class TendersController {
|
|||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
if (!fs.existsSync(file)) {
|
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(
|
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))
|
return res.sendFile(path.resolve(file))
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error('[tenders.viewAttachment]', error)
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
|
||||||
const TENDER_SOURCE_VALUES = [
|
const TENDER_SOURCE_VALUES = [
|
||||||
@@ -747,7 +748,27 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
) {
|
) {
|
||||||
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
||||||
if (!tender) throw new AppError(404, 'Tender not found');
|
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({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
@@ -757,11 +778,12 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: absolutePath,
|
||||||
category: category || 'ANNOUNCEMENT',
|
category: category || 'ANNOUNCEMENT',
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
entityId: tenderId,
|
entityId: tenderId,
|
||||||
@@ -769,6 +791,7 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
userId,
|
userId,
|
||||||
changes: { attachmentUploaded: attachment.id },
|
changes: { attachmentUploaded: attachment.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,8 +805,29 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
where: { id: directiveId },
|
where: { id: directiveId },
|
||||||
select: { id: true, tenderId: true },
|
select: { id: true, tenderId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!directive) throw new AppError(404, 'Directive not found');
|
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({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
@@ -794,11 +838,12 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: absolutePath,
|
||||||
category: category || 'TASK_FILE',
|
category: category || 'TASK_FILE',
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
entityId: directiveId,
|
entityId: directiveId,
|
||||||
@@ -806,17 +851,62 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
userId,
|
userId,
|
||||||
changes: { attachmentUploaded: attachment.id },
|
changes: { attachmentUploaded: attachment.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||||
const attachment = await prisma.attachment.findUnique({
|
const attachment = await prisma.attachment.findUnique({
|
||||||
where: { id: attachmentId },
|
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<void> {
|
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||||
@@ -826,12 +916,10 @@ private getEffectiveTenderStatus(tender: {
|
|||||||
|
|
||||||
if (!attachment) throw new AppError(404, 'File not found')
|
if (!attachment) throw new AppError(404, 'File not found')
|
||||||
|
|
||||||
// حذف من الديسك
|
|
||||||
if (attachment.path && fs.existsSync(attachment.path)) {
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||||
fs.unlinkSync(attachment.path)
|
fs.unlinkSync(attachment.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// حذف من DB
|
|
||||||
await prisma.attachment.delete({
|
await prisma.attachment.delete({
|
||||||
where: { id: attachmentId },
|
where: { id: attachmentId },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
BCRYPT_ROUNDS: 10
|
BCRYPT_ROUNDS: 10
|
||||||
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
|
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -71,3 +71,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
backend_uploads:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||||
{ id: 'notify', name: 'إشعار' },
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||||
{ id: 'notify', name: 'إشعار' },
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type ExpenseClaimLine = {
|
|||||||
type ExpenseClaimFormState = {
|
type ExpenseClaimFormState = {
|
||||||
items: ExpenseClaimLine[];
|
items: ExpenseClaimLine[];
|
||||||
description: string;
|
description: string;
|
||||||
attachment: File | null;
|
attachments: File[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyLine = (): ExpenseClaimLine => ({
|
const emptyLine = (): ExpenseClaimLine => ({
|
||||||
@@ -32,7 +32,7 @@ const emptyLine = (): ExpenseClaimLine => ({
|
|||||||
const initialForm: ExpenseClaimFormState = {
|
const initialForm: ExpenseClaimFormState = {
|
||||||
items: [emptyLine()],
|
items: [emptyLine()],
|
||||||
description: '',
|
description: '',
|
||||||
attachment: null,
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatusLabel(status: string) {
|
function getStatusLabel(status: string) {
|
||||||
@@ -178,7 +178,7 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
await portalAPI.submitExpenseClaim({
|
await portalAPI.submitExpenseClaim({
|
||||||
items,
|
items,
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
attachment: form.attachment,
|
attachments: form.attachments,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,6 +286,27 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{claim.status === 'APPROVED' ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-sm ${
|
||||||
|
claim.isPaid
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(claim.isPaid)}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
className="h-4 w-4 cursor-not-allowed accent-emerald-600"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">
|
||||||
|
{claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-800">
|
<span className="font-medium text-gray-800">
|
||||||
@@ -563,31 +584,58 @@ export default function PortalExpenseClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
مرفق
|
المرفقات
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept="image/*,application/pdf"
|
accept="image/*,application/pdf"
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setForm((prev) => ({
|
const picked = Array.from(e.target.files || []);
|
||||||
...prev,
|
if (picked.length === 0) return;
|
||||||
attachment: e.target.files?.[0] || null,
|
|
||||||
}))
|
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"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{form.attachment ? (
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
<div className="mt-2 flex items-center justify-between rounded-lg bg-gray-50 border px-3 py-2 text-sm text-gray-700">
|
يمكن اختيار أكثر من ملف (حتى 10 ملفات).
|
||||||
<span>{form.attachment.name}</span>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
{form.attachments.length > 0 ? (
|
||||||
onClick={() => setForm((prev) => ({ ...prev, attachment: null }))}
|
<div className="mt-2 space-y-1">
|
||||||
className="text-red-600 hover:underline"
|
{form.attachments.map((file, idx) => (
|
||||||
>
|
<div
|
||||||
إزالة
|
key={`${file.name}-${idx}`}
|
||||||
</button>
|
className="flex items-center justify-between rounded-lg bg-gray-50 border px-3 py-2 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
attachments: prev.attachments.filter((_, i) => i !== idx),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="text-red-600 hover:underline shrink-0 ms-2"
|
||||||
|
>
|
||||||
|
إزالة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
|
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -39,10 +40,15 @@ function getStatusClasses(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ManagedExpenseClaimsPage() {
|
export default function ManagedExpenseClaimsPage() {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canMarkAsPaid = hasPermission('department_expense_claims', 'mark-as-paid');
|
||||||
|
|
||||||
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState('PENDING');
|
const [statusFilter, setStatusFilter] = useState('PENDING');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
|
const [payingId, setPayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
const [selectedClaim, setSelectedClaim] = useState<ExpenseClaim | null>(null);
|
const [selectedClaim, setSelectedClaim] = useState<ExpenseClaim | null>(null);
|
||||||
@@ -51,11 +57,12 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const claimId = searchParams.get('claimId');
|
const claimId = searchParams.get('claimId');
|
||||||
|
|
||||||
async function loadClaims(status = statusFilter) {
|
async function loadClaims(status = statusFilter, search = searchQuery) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await portalAPI.getManagedExpenseClaims(
|
const data = await portalAPI.getManagedExpenseClaims(
|
||||||
status === 'all' ? undefined : status
|
status === 'all' ? undefined : status,
|
||||||
|
search.trim() || undefined,
|
||||||
);
|
);
|
||||||
setClaims(data);
|
setClaims(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -66,8 +73,13 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadClaims(statusFilter);
|
// Debounce the search so we don't fire a request on every keystroke.
|
||||||
}, [statusFilter]);
|
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) {
|
async function openAttachment(attachment: any) {
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +108,7 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
try {
|
try {
|
||||||
setSubmittingId(id);
|
setSubmittingId(id);
|
||||||
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
|
||||||
await loadClaims(statusFilter);
|
await loadClaims(statusFilter, searchQuery);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,7 +141,7 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
setRejectModalOpen(false);
|
setRejectModalOpen(false);
|
||||||
setSelectedClaim(null);
|
setSelectedClaim(null);
|
||||||
setRejectReason('');
|
setRejectReason('');
|
||||||
await loadClaims(statusFilter);
|
await loadClaims(statusFilter, searchQuery);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6" dir="rtl">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
@@ -149,18 +192,43 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<label className="text-sm text-gray-600">الحالة:</label>
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<label className="text-sm text-gray-600">بحث عن موظف:</label>
|
||||||
value={statusFilter}
|
<div className="relative">
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
<input
|
||||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
type="text"
|
||||||
>
|
value={searchQuery}
|
||||||
<option value="PENDING">قيد المراجعة</option>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<option value="APPROVED">مقبول</option>
|
placeholder="الاسم أو الرقم الوظيفي"
|
||||||
<option value="REJECTED">مرفوض</option>
|
className="w-64 rounded-lg border border-gray-300 px-3 py-2 pe-8 text-sm"
|
||||||
<option value="all">الكل</option>
|
/>
|
||||||
</select>
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute inset-y-0 end-2 my-auto h-5 w-5 rounded-full text-gray-400 hover:text-gray-700"
|
||||||
|
aria-label="مسح البحث"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600">الحالة:</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="PENDING">قيد المراجعة</option>
|
||||||
|
<option value="APPROVED">مقبول</option>
|
||||||
|
<option value="REJECTED">مرفوض</option>
|
||||||
|
<option value="all">الكل</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -365,6 +433,37 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{claim.status === 'APPROVED' ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-sm ${
|
||||||
|
claim.isPaid
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||||
|
: 'border-gray-200 bg-gray-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`paid-${claim.id}`}
|
||||||
|
checked={Boolean(claim.isPaid)}
|
||||||
|
disabled={!canMarkAsPaid || payingId === claim.id}
|
||||||
|
onChange={(e) => handleTogglePaid(claim, e.target.checked)}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-emerald-600 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`paid-${claim.id}`}
|
||||||
|
className={`font-medium ${canMarkAsPaid ? 'cursor-pointer' : 'cursor-default'}`}
|
||||||
|
>
|
||||||
|
{claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'}
|
||||||
|
</label>
|
||||||
|
{payingId === claim.id && (
|
||||||
|
<span className="text-xs text-gray-500">جارٍ الحفظ...</span>
|
||||||
|
)}
|
||||||
|
{!canMarkAsPaid && (
|
||||||
|
<span className="text-xs text-gray-500">(للعرض فقط)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{claim.status === 'PENDING' ? (
|
{claim.status === 'PENDING' ? (
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -212,16 +212,34 @@ function TenderDetailContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const files = Array.from(e.target.files || [])
|
||||||
if (!file) return
|
if (!files.length) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||||
toast.success(t('tenders.uploadFile'))
|
for (const file of files) {
|
||||||
fetchTender()
|
try {
|
||||||
} catch (err: any) {
|
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||||
toast.error(err.response?.data?.message || 'Upload failed')
|
successCount++
|
||||||
|
} catch (err: any) {
|
||||||
|
failCount++
|
||||||
|
const msg = err.response?.data?.message || 'Upload failed'
|
||||||
|
toast.error(`${file.name}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(
|
||||||
|
files.length === 1
|
||||||
|
? t('tenders.uploadFile')
|
||||||
|
: `${successCount}/${files.length} ✓`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (successCount > 0) fetchTender()
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@@ -234,20 +252,35 @@ function TenderDetailContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const files = Array.from(e.target.files || [])
|
||||||
const directiveId = directiveIdForUpload
|
const directiveId = directiveIdForUpload
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
setDirectiveIdForUpload(null)
|
setDirectiveIdForUpload(null)
|
||||||
|
|
||||||
if (!file || !directiveId) return
|
if (!files.length || !directiveId) return
|
||||||
|
|
||||||
setUploadingDirectiveId(directiveId)
|
setUploadingDirectiveId(directiveId)
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
for (const file of files) {
|
||||||
toast.success(t('tenders.uploadFile'))
|
try {
|
||||||
fetchTender()
|
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||||
} catch (err: any) {
|
successCount++
|
||||||
toast.error(err.response?.data?.message || 'Upload failed')
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Upload failed'
|
||||||
|
toast.error(`${file.name}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(
|
||||||
|
files.length === 1
|
||||||
|
? t('tenders.uploadFile')
|
||||||
|
: `${successCount}/${files.length} ✓`
|
||||||
|
)
|
||||||
|
fetchTender()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDirectiveId(null)
|
setUploadingDirectiveId(null)
|
||||||
}
|
}
|
||||||
@@ -462,6 +495,7 @@ function TenderDetailContent() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={directiveFileInputRef}
|
ref={directiveFileInputRef}
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDirectiveFileUpload}
|
onChange={handleDirectiveFileUpload}
|
||||||
/>
|
/>
|
||||||
@@ -493,6 +527,7 @@ function TenderDetailContent() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleTenderFileUpload}
|
onChange={handleTenderFileUpload}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Permission {
|
|||||||
canDelete?: boolean
|
canDelete?: boolean
|
||||||
canExport?: boolean
|
canExport?: boolean
|
||||||
canApprove?: boolean
|
canApprove?: boolean
|
||||||
|
canMarkAsPaid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -37,7 +38,7 @@ interface AuthContextType {
|
|||||||
logout: () => void
|
logout: () => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve') => boolean
|
hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid') => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
canDelete: wildcard || p.actions?.includes('delete') || false,
|
canDelete: wildcard || p.actions?.includes('delete') || false,
|
||||||
canExport: wildcard || p.actions?.includes('export') || false,
|
canExport: wildcard || p.actions?.includes('export') || false,
|
||||||
canApprove: wildcard || p.actions?.includes('approve') || false,
|
canApprove: wildcard || p.actions?.includes('approve') || false,
|
||||||
|
canMarkAsPaid: wildcard || p.actions?.includes('mark-as-paid') || false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve'): boolean => {
|
const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid'): boolean => {
|
||||||
if (!user?.role?.permissions) return false
|
if (!user?.role?.permissions) return false
|
||||||
|
|
||||||
const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase())
|
const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase())
|
||||||
@@ -160,7 +162,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
edit: 'canEdit',
|
edit: 'canEdit',
|
||||||
delete: 'canDelete',
|
delete: 'canDelete',
|
||||||
export: 'canExport',
|
export: 'canExport',
|
||||||
approve: 'canApprove'
|
approve: 'canApprove',
|
||||||
|
'mark-as-paid': 'canMarkAsPaid'
|
||||||
}
|
}
|
||||||
|
|
||||||
return permission[actionMap[action] as keyof Permission] as boolean
|
return permission[actionMap[action] as keyof Permission] as boolean
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export interface ExpenseClaim {
|
|||||||
approvedAt?: string | null;
|
approvedAt?: string | null;
|
||||||
rejectedReason?: string | null;
|
rejectedReason?: string | null;
|
||||||
approvalNote?: string | null;
|
approvalNote?: string | null;
|
||||||
|
isPaid?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
attachments?: Array<{
|
attachments?: Array<{
|
||||||
@@ -316,7 +317,7 @@ export const portalAPI = {
|
|||||||
proofRef?: string;
|
proofRef?: string;
|
||||||
}>;
|
}>;
|
||||||
description?: string;
|
description?: string;
|
||||||
attachment?: File | null;
|
attachments?: File[];
|
||||||
}): Promise<ExpenseClaim> => {
|
}): Promise<ExpenseClaim> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@@ -326,20 +327,23 @@ export const portalAPI = {
|
|||||||
formData.append('description', data.description);
|
formData.append('description', data.description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.attachment) {
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
formData.append('attachment', data.attachment);
|
for (const file of data.attachments) {
|
||||||
|
formData.append('attachments', file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/hr/portal/expense-claims', formData, {
|
const response = await api.post('/hr/portal/expense-claims', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': undefined as any },
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getManagedExpenseClaims: async (status?: string): Promise<ExpenseClaim[]> => {
|
getManagedExpenseClaims: async (status?: string, search?: string): Promise<ExpenseClaim[]> => {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
if (status && status !== 'all') q.append('status', status)
|
if (status && status !== 'all') q.append('status', status)
|
||||||
|
if (search && search.trim()) q.append('search', search.trim())
|
||||||
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
},
|
},
|
||||||
@@ -357,6 +361,11 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markExpenseClaimPaid: async (id: string, isPaid: boolean): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.patch(`/hr/portal/managed-expense-claims/${id}/paid`, { isPaid })
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (month) params.append('month', String(month))
|
if (month) params.append('month', String(month))
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export const tendersAPI = {
|
|||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (category) formData.append('category', category)
|
if (category) formData.append('category', category)
|
||||||
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
|
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': undefined as any },
|
||||||
})
|
})
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
@@ -186,7 +186,7 @@ export const tendersAPI = {
|
|||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (category) formData.append('category', category)
|
if (category) formData.append('category', category)
|
||||||
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
|
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': undefined as any },
|
||||||
})
|
})
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
@@ -209,4 +209,4 @@ export const tendersAPI = {
|
|||||||
const response = await api.get('/tenders/directive-type-values')
|
const response = await api.get('/tenders/directive-type-values')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user