add suppliers
This commit is contained in:
@@ -29,6 +29,7 @@ class ContactsController {
|
|||||||
const filters = {
|
const filters = {
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
type: req.query.type as string,
|
type: req.query.type as string,
|
||||||
|
specialization: req.query.specialization as string,
|
||||||
status: req.query.status as string,
|
status: req.query.status as string,
|
||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
source: req.query.source as string,
|
source: req.query.source as string,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ router.post(
|
|||||||
authorize('contacts', 'contacts', 'create'),
|
authorize('contacts', 'contacts', 'create'),
|
||||||
[
|
[
|
||||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
||||||
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION','SUPPLIER',]),
|
||||||
body('name').notEmpty().trim(),
|
body('name').notEmpty().trim(),
|
||||||
body('email').optional().isEmail(),
|
body('email').optional().isEmail(),
|
||||||
body('source').notEmpty(),
|
body('source').notEmpty(),
|
||||||
@@ -73,6 +73,7 @@ router.put(
|
|||||||
'UN',
|
'UN',
|
||||||
'NGO',
|
'NGO',
|
||||||
'INSTITUTION',
|
'INSTITUTION',
|
||||||
|
'SUPPLIER',
|
||||||
]),
|
]),
|
||||||
body('email')
|
body('email')
|
||||||
.optional({ values: 'falsy' })
|
.optional({ values: 'falsy' })
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface UpdateContactData extends Partial<CreateContactData> {
|
|||||||
interface SearchFilters {
|
interface SearchFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
specialization?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -148,6 +149,12 @@ class ContactsService {
|
|||||||
where.type = filters.type;
|
where.type = filters.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.specialization) {
|
||||||
|
where.tags = {
|
||||||
|
has: filters.specialization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
where.status = filters.status;
|
where.status = filters.status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,22 @@ if (!fs.existsSync(expenseClaimsUploadDir)) {
|
|||||||
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
|
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodeOriginalFileName = (name: string) => {
|
||||||
|
return Buffer.from(name, 'latin1').toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
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) => {
|
||||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
const originalName = decodeOriginalFileName(file.originalname);
|
||||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
const ext = path.extname(originalName);
|
||||||
|
const safeBaseName = path
|
||||||
|
.basename(originalName, ext)
|
||||||
|
.replace(/[^a-zA-Z0-9\u0600-\u06FF._-]/g, '_');
|
||||||
|
|
||||||
|
(file as any).decodedOriginalName = originalName;
|
||||||
|
|
||||||
|
cb(null, `${crypto.randomUUID()}-${safeBaseName}${ext}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
class HRService {
|
class HRService {
|
||||||
// ========== EMPLOYEES ==========
|
// ========== EMPLOYEES ==========
|
||||||
@@ -352,15 +353,40 @@ class HRService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId,
|
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;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -378,16 +404,25 @@ class HRService {
|
|||||||
const year = new Date(leave.startDate).getFullYear();
|
const year = new Date(leave.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'APPROVE',
|
action: 'APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
type: 'LEAVE_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الإجازة',
|
||||||
|
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: leave.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return leave;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
const leave = await prisma.leave.findUnique({ where: { id } });
|
const leave = await prisma.leave.findUnique({ where: { id } });
|
||||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
@@ -403,13 +438,26 @@ class HRService {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
userId,
|
userId,
|
||||||
reason: rejectedReason,
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,13 +614,23 @@ async findManagedLeaves(status?: string) {
|
|||||||
const year = new Date(updated.startDate).getFullYear();
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_APPROVE',
|
action: 'MANAGER_APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: updated.employeeId,
|
||||||
|
type: 'LEAVE_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الإجازة',
|
||||||
|
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +668,7 @@ async findManagedLeaves(status?: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_REJECT',
|
action: 'MANAGER_REJECT',
|
||||||
@@ -618,6 +676,18 @@ async findManagedLeaves(status?: string) {
|
|||||||
reason: rejectedReason,
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +913,30 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
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: [],
|
||||||
|
});
|
||||||
|
|
||||||
return loan;
|
return loan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,7 +982,7 @@ private isSystemAdminUser(user: any) {
|
|||||||
const loanAmount = Number(loan.amount || 0);
|
const loanAmount = Number(loan.amount || 0);
|
||||||
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
||||||
|
|
||||||
// المرحلة الأولى: HR approval
|
|
||||||
if (loan.status === 'PENDING_HR') {
|
if (loan.status === 'PENDING_HR') {
|
||||||
if (needsAdminApproval) {
|
if (needsAdminApproval) {
|
||||||
const updatedLoan = await prisma.loan.update({
|
const updatedLoan = await prisma.loan.update({
|
||||||
@@ -899,13 +992,36 @@ private isSystemAdminUser(user: any) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
userId,
|
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;
|
return updatedLoan;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -954,14 +1070,26 @@ private isSystemAdminUser(user: any) {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.findLoanById(id);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||||
@@ -981,7 +1109,7 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
@@ -989,6 +1117,18 @@ private isSystemAdminUser(user: any) {
|
|||||||
reason: rejectedReason,
|
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;
|
return loan;
|
||||||
}
|
}
|
||||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||||
@@ -1063,7 +1203,30 @@ private isSystemAdminUser(user: any) {
|
|||||||
},
|
},
|
||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
|
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: [],
|
||||||
|
});
|
||||||
|
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,8 +1236,19 @@ private isSystemAdminUser(user: any) {
|
|||||||
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
||||||
return req;
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: req.employeeId,
|
||||||
|
type: 'PURCHASE_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الشراء',
|
||||||
|
message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
|
||||||
|
entityType: 'PURCHASE_REQUEST',
|
||||||
|
entityId: req.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return req;;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
||||||
@@ -1084,6 +1258,19 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
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;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
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'
|
||||||
@@ -208,11 +209,25 @@ class TendersService {
|
|||||||
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
|
private getComputedTenderStatus<T extends { status?: string | null; closingDate?: Date | string | null }>(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<T extends { notes?: string | null; bondValue?: any; status?: string | null; closingDate?: Date | string | null }>(tender: T) {
|
||||||
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tender,
|
...tender,
|
||||||
|
status: this.getComputedTenderStatus(tender),
|
||||||
|
originalStatus: tender.status,
|
||||||
notes: cleanNotes || null,
|
notes: cleanNotes || null,
|
||||||
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
||||||
finalBondValue: meta.finalBondValue ?? null,
|
finalBondValue: meta.finalBondValue ?? null,
|
||||||
@@ -345,7 +360,9 @@ class TendersService {
|
|||||||
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (filters.status) where.status = filters.status;
|
if (filters.status && filters.status !== 'EXPIRED') {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
if (filters.source) where.source = filters.source;
|
if (filters.source) where.source = filters.source;
|
||||||
if (filters.announcementType) where.announcementType = filters.announcementType;
|
if (filters.announcementType) where.announcementType = filters.announcementType;
|
||||||
|
|
||||||
@@ -361,9 +378,15 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return {
|
const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t));
|
||||||
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
|
const filteredTenders =
|
||||||
total,
|
filters.status === 'EXPIRED'
|
||||||
|
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
|
||||||
|
: mappedTenders;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenders: filteredTenders,
|
||||||
|
total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
@@ -517,20 +540,20 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignedUser = directive.assignedToEmployee?.user;
|
const assignedUser = directive.assignedToEmployee?.user;
|
||||||
if (assignedUser?.id) {
|
if (assignedUser?.id) {
|
||||||
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
||||||
await prisma.notification.create({
|
|
||||||
data: {
|
await notificationsService.notifyMany({
|
||||||
userId: assignedUser.id,
|
userIds: [assignedUser.id],
|
||||||
type: 'TENDER_DIRECTIVE_ASSIGNED',
|
type: 'TENDER_DIRECTIVE_ASSIGNED',
|
||||||
title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
|
title: 'تم إسناد توجيه مناقصة جديد',
|
||||||
message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
|
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`,
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER',
|
||||||
entityId: directive.id,
|
entityId: tender.id,
|
||||||
},
|
excludeUserIds: [userId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
@@ -678,7 +701,17 @@ class TendersService {
|
|||||||
return deal;
|
return deal;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadTenderAttachment(
|
private decodeUploadedFileName(fileName: string) {
|
||||||
|
if (!fileName) return 'file';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Buffer.from(fileName, 'latin1').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadTenderAttachment(
|
||||||
tenderId: string,
|
tenderId: string,
|
||||||
file: { path: string; originalname: string; mimetype: string; size: number },
|
file: { path: string; originalname: string; mimetype: string; size: number },
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -686,14 +719,17 @@ class TendersService {
|
|||||||
) {
|
) {
|
||||||
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 fileName = path.basename(file.path);
|
||||||
|
const originalName = this.decodeUploadedFileName(file.originalname);
|
||||||
|
|
||||||
const attachment = await prisma.attachment.create({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
entityId: tenderId,
|
entityId: tenderId,
|
||||||
tenderId,
|
tenderId,
|
||||||
fileName,
|
fileName,
|
||||||
originalName: file.originalname,
|
originalName,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
@@ -701,6 +737,7 @@ class TendersService {
|
|||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
entityId: tenderId,
|
entityId: tenderId,
|
||||||
@@ -708,6 +745,7 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
changes: { attachmentUploaded: attachment.id },
|
changes: { attachmentUploaded: attachment.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,8 +759,12 @@ class TendersService {
|
|||||||
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 fileName = path.basename(file.path);
|
||||||
|
const originalName = this.decodeUploadedFileName(file.originalname);
|
||||||
|
|
||||||
const attachment = await prisma.attachment.create({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
@@ -730,7 +772,7 @@ class TendersService {
|
|||||||
tenderDirectiveId: directiveId,
|
tenderDirectiveId: directiveId,
|
||||||
tenderId: directive.tenderId,
|
tenderId: directive.tenderId,
|
||||||
fileName,
|
fileName,
|
||||||
originalName: file.originalname,
|
originalName,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
@@ -738,6 +780,7 @@ class TendersService {
|
|||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
entityId: directiveId,
|
entityId: directiveId,
|
||||||
@@ -745,9 +788,9 @@ class TendersService {
|
|||||||
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 },
|
||||||
@@ -765,12 +808,10 @@ class TendersService {
|
|||||||
|
|
||||||
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 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function ContactsContent() {
|
|||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedType, setSelectedType] = useState('all')
|
const [selectedType, setSelectedType] = useState('all')
|
||||||
|
const [selectedSpecialization, setSelectedSpecialization] = useState('all')
|
||||||
|
const [createDefaultType, setCreateDefaultType] = useState('INDIVIDUAL')
|
||||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
const [selectedSource, setSelectedSource] = useState('all')
|
const [selectedSource, setSelectedSource] = useState('all')
|
||||||
const [selectedRating, setSelectedRating] = useState('all')
|
const [selectedRating, setSelectedRating] = useState('all')
|
||||||
@@ -82,6 +84,7 @@ function ContactsContent() {
|
|||||||
|
|
||||||
if (searchTerm) filters.search = searchTerm
|
if (searchTerm) filters.search = searchTerm
|
||||||
if (selectedType !== 'all') filters.type = selectedType
|
if (selectedType !== 'all') filters.type = selectedType
|
||||||
|
if (selectedSpecialization !== 'all') filters.specialization = selectedSpecialization
|
||||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||||
if (selectedSource !== 'all') filters.source = selectedSource
|
if (selectedSource !== 'all') filters.source = selectedSource
|
||||||
if (selectedRating !== 'all') filters.rating = parseInt(selectedRating)
|
if (selectedRating !== 'all') filters.rating = parseInt(selectedRating)
|
||||||
@@ -97,7 +100,7 @@ function ContactsContent() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const debounce = setTimeout(() => {
|
const debounce = setTimeout(() => {
|
||||||
@@ -109,7 +112,7 @@ function ContactsContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
|
||||||
|
|
||||||
const handleCreate = async (data: CreateContactData) => {
|
const handleCreate = async (data: CreateContactData) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -247,6 +250,21 @@ function ContactsContent() {
|
|||||||
return (contact as any).nameAr || ''
|
return (contact as any).nameAr || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supplierSpecializations = [
|
||||||
|
'كاميرات',
|
||||||
|
'شبكات',
|
||||||
|
'أجهزة كومبيوتر',
|
||||||
|
'projectors',
|
||||||
|
'مقاسم هاتفية',
|
||||||
|
' Mobile - Tablet',
|
||||||
|
'firewall',
|
||||||
|
'طاقة بديلة',
|
||||||
|
'حديد',
|
||||||
|
' باركود - POS',
|
||||||
|
'أجهزة منزلية',
|
||||||
|
'تكييف وتبريد',
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<header className="bg-white shadow-sm border-b">
|
<header className="bg-white shadow-sm border-b">
|
||||||
@@ -307,8 +325,20 @@ function ContactsContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetForm()
|
resetForm()
|
||||||
|
setCreateDefaultType('SUPPLIER')
|
||||||
setShowCreateModal(true)
|
setShowCreateModal(true)
|
||||||
}}
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
إضافة موردين
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setCreateDefaultType('INDIVIDUAL')
|
||||||
|
setShowCreateModal(true)
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -406,8 +436,10 @@ function ContactsContent() {
|
|||||||
<option value="UN">UN</option>
|
<option value="UN">UN</option>
|
||||||
<option value="NGO">NGO</option>
|
<option value="NGO">NGO</option>
|
||||||
<option value="INSTITUTION">Institution</option>
|
<option value="INSTITUTION">Institution</option>
|
||||||
|
<option value="SUPPLIER">Suppliers - موردين</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
@@ -452,7 +484,23 @@ function ContactsContent() {
|
|||||||
<option value="OTHER">Other</option>
|
<option value="OTHER">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
اختصاص المورد
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSpecialization}
|
||||||
|
onChange={(e) => setSelectedSpecialization(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
|
>
|
||||||
|
<option value="all">كل الاختصاصات</option>
|
||||||
|
{supplierSpecializations.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||||
<select
|
<select
|
||||||
@@ -493,6 +541,7 @@ function ContactsContent() {
|
|||||||
setSelectedRating('all')
|
setSelectedRating('all')
|
||||||
setSelectedCategory('all')
|
setSelectedCategory('all')
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
|
setSelectedSpecialization('all')
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -528,8 +577,8 @@ function ContactsContent() {
|
|||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Create First Contact
|
{createDefaultType === 'SUPPLIER' ? 'إضافة مورد' : 'Create New Contact'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -732,7 +781,8 @@ function ContactsContent() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
key="create-contact"
|
key={`create-${createDefaultType}`}
|
||||||
|
defaultType={createDefaultType}
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await handleCreate(data as CreateContactData)
|
await handleCreate(data as CreateContactData)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -337,15 +337,14 @@ export default function ManagedExpenseClaimsPage() {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{claim.attachments.map((attachment) => (
|
{claim.attachments.map((attachment) => (
|
||||||
<a
|
<button
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => openAttachment(attachment)}
|
||||||
rel="noreferrer"
|
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
|
||||||
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
>
|
||||||
{attachment.originalName}
|
{attachment.originalName}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ContactFormProps {
|
|||||||
onSubmit: (data: CreateContactData | UpdateContactData) => Promise<void>
|
onSubmit: (data: CreateContactData | UpdateContactData) => Promise<void>
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
|
defaultType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
||||||
@@ -39,10 +40,12 @@ const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
|||||||
customFields: contact?.customFields
|
customFields: contact?.customFields
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false, defaultType = 'INDIVIDUAL' }: ContactFormProps) { const isEdit = !!contact
|
||||||
const isEdit = !!contact
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
|
const [formData, setFormData] = useState<CreateContactData>({
|
||||||
|
...buildInitialFormData(contact),
|
||||||
|
type: contact?.type || defaultType,
|
||||||
|
})
|
||||||
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
||||||
const [newTag, setNewTag] = useState('')
|
const [newTag, setNewTag] = useState('')
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
@@ -50,11 +53,13 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
const [employees, setEmployees] = useState<Employee[]>([])
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormData(buildInitialFormData(contact))
|
setFormData({
|
||||||
|
...buildInitialFormData(contact),
|
||||||
|
type: contact?.type || defaultType,
|
||||||
|
})
|
||||||
setRating(contact?.rating || 0)
|
setRating(contact?.rating || 0)
|
||||||
setNewTag('')
|
|
||||||
setFormErrors({})
|
setFormErrors({})
|
||||||
}, [contact])
|
}, [contact, defaultType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
||||||
@@ -92,9 +97,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
'NGO',
|
'NGO',
|
||||||
'INSTITUTION',
|
'INSTITUTION',
|
||||||
])
|
])
|
||||||
|
const isSupplier = formData.type === 'SUPPLIER'
|
||||||
const isOrganizationType = organizationTypes.has(formData.type)
|
const isOrganizationType = organizationTypes.has(formData.type)
|
||||||
const showCompanyFields = isOrganizationType
|
const showCompanyFields = isOrganizationType && !isSupplier
|
||||||
|
const supplierSpecializations = [
|
||||||
|
'كاميرات',
|
||||||
|
'شبكات',
|
||||||
|
'أجهزة كومبيوتر',
|
||||||
|
'projectors',
|
||||||
|
'مقاسم هاتفية',
|
||||||
|
' Mobile - Tablet',
|
||||||
|
'firewall',
|
||||||
|
'طاقة بديلة',
|
||||||
|
'حديد',
|
||||||
|
' باركود - POS',
|
||||||
|
'أجهزة منزلية',
|
||||||
|
'تكييف وتبريد',
|
||||||
|
]
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
@@ -103,6 +122,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
errors.name = 'Name must be at least 2 characters'
|
errors.name = 'Name must be at least 2 characters'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSupplier && (!formData.tags || formData.tags.length === 0)) {
|
||||||
|
errors.tags = 'الاختصاص مطلوب'
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
errors.email = 'Invalid email format'
|
errors.email = 'Invalid email format'
|
||||||
}
|
}
|
||||||
@@ -158,8 +181,8 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cleanData.parentId) {
|
if (!cleanData.parentId) {
|
||||||
delete cleanData.parentId
|
delete cleanData.parentId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||||
delete cleanData.categories
|
delete cleanData.categories
|
||||||
@@ -223,6 +246,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
<option value="UN">UN - الأمم المتحدة</option>
|
<option value="UN">UN - الأمم المتحدة</option>
|
||||||
<option value="NGO">NGO - منظمة غير حكومية</option>
|
<option value="NGO">NGO - منظمة غير حكومية</option>
|
||||||
<option value="INSTITUTION">Institution - مؤسسة</option>
|
<option value="INSTITUTION">Institution - مؤسسة</option>
|
||||||
|
<option value="SUPPLIER">Supplier - مورد</option>
|
||||||
</select>
|
</select>
|
||||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,50 +275,106 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
|
{isSupplier ? 'اسم المورد' : isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
|
placeholder={isSupplier ? 'أدخل اسم المورد' : isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
|
||||||
/>
|
/>
|
||||||
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{isSupplier && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div>
|
||||||
Rating
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
</label>
|
الاختصاص <span className="text-red-500">*</span>
|
||||||
<div className="flex items-center gap-2">
|
</label>
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<button
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
key={star}
|
{supplierSpecializations.map((item) => {
|
||||||
type="button"
|
const checked = formData.tags?.includes(item) || false
|
||||||
onClick={() => setRating(star)}
|
return (
|
||||||
className="focus:outline-none transition-colors"
|
<button
|
||||||
>
|
key={item}
|
||||||
<Star
|
type="button"
|
||||||
className={`h-8 w-8 ${
|
onClick={() => {
|
||||||
star <= rating
|
setFormData({
|
||||||
? 'fill-yellow-400 text-yellow-400'
|
...formData,
|
||||||
: 'text-gray-300 hover:text-yellow-200'
|
tags: checked
|
||||||
}`}
|
? (formData.tags || []).filter((tag) => tag !== item)
|
||||||
/>
|
: [...(formData.tags || []), item],
|
||||||
</button>
|
})
|
||||||
))}
|
}}
|
||||||
{rating > 0 && (
|
className={`px-3 py-1 rounded-full border text-sm transition-colors ${
|
||||||
|
checked
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
|
placeholder="أضف اختصاص آخر"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRating(0)}
|
onClick={addTag}
|
||||||
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Clear
|
<Plus className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{formErrors.tags && <p className="text-red-500 text-xs mt-1">{formErrors.tags}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{!isSupplier && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rating
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRating(star)}
|
||||||
|
className="focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`h-8 w-8 ${
|
||||||
|
star <= rating
|
||||||
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
|
: 'text-gray-300 hover:text-yellow-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{rating > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRating(0)}
|
||||||
|
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -413,64 +493,76 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
<div className="space-y-4">
|
{isSupplier ? 'العنوان' : 'Address Information'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isSupplier ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street Address
|
العنوان
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address || ''}
|
value={formData.address || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="Street address"
|
placeholder="العنوان"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
City
|
Street Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.city || ''}
|
value={formData.address || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="City"
|
placeholder="Street address"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div>
|
||||||
Country
|
<label className="block text-sm font-medium text-gray-700 mb-1">City</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={formData.city || ''}
|
||||||
value={formData.country || ''}
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
placeholder="City"
|
||||||
placeholder="Country"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Country</label>
|
||||||
Postal Code
|
<input
|
||||||
</label>
|
type="text"
|
||||||
<input
|
value={formData.country || ''}
|
||||||
type="text"
|
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||||||
value={formData.postalCode || ''}
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
placeholder="Country"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
/>
|
||||||
placeholder="Postal code"
|
</div>
|
||||||
/>
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.postalCode || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
|
placeholder="Postal code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isSupplier && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
@@ -479,6 +571,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
multiSelect={true}
|
multiSelect={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isCompanyEmployeeSelected && (
|
{isCompanyEmployeeSelected && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
@@ -501,48 +594,50 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-6 border-t">
|
{!isSupplier && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
<div className="pt-6 border-t">
|
||||||
<div className="space-y-3">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
||||||
<div className="flex gap-2">
|
<div className="space-y-3">
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={newTag}
|
type="text"
|
||||||
onChange={(e) => setNewTag(e.target.value)}
|
value={newTag}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||||
placeholder="Add a tag (press Enter)"
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
/>
|
placeholder="Add a tag (press Enter)"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={addTag}
|
type="button"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
onClick={addTag}
|
||||||
>
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
<Plus className="h-5 w-5" />
|
>
|
||||||
</button>
|
<Plus className="h-5 w-5" />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{formData.tags && formData.tags.length > 0 && (
|
{formData.tags && formData.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formData.tags.map((tag, index) => (
|
{formData.tags.map((tag, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeTag(tag)}
|
onClick={() => removeTag(tag)}
|
||||||
className="hover:text-red-600 transition-colors"
|
className="hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DuplicateAlert
|
<DuplicateAlert
|
||||||
email={formData.email}
|
email={formData.email}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface UpdateContactData extends Partial<CreateContactData> {
|
|||||||
export interface ContactFilters {
|
export interface ContactFilters {
|
||||||
search?: string
|
search?: string
|
||||||
type?: string
|
type?: string
|
||||||
|
specialization?: string
|
||||||
status?: string
|
status?: string
|
||||||
category?: string
|
category?: string
|
||||||
source?: string
|
source?: string
|
||||||
@@ -87,6 +88,7 @@ export const contactsAPI = {
|
|||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filters.search) params.append('search', filters.search)
|
if (filters.search) params.append('search', filters.search)
|
||||||
if (filters.type) params.append('type', filters.type)
|
if (filters.type) params.append('type', filters.type)
|
||||||
|
if (filters.specialization) params.append('specialization', filters.specialization)
|
||||||
if (filters.status) params.append('status', filters.status)
|
if (filters.status) params.append('status', filters.status)
|
||||||
if (filters.category) params.append('category', filters.category)
|
if (filters.category) params.append('category', filters.category)
|
||||||
if (filters.source) params.append('source', filters.source)
|
if (filters.source) params.append('source', filters.source)
|
||||||
|
|||||||
Reference in New Issue
Block a user