add suppliers

This commit is contained in:
Aya
2026-05-03 15:25:50 +03:00
parent 287401f1da
commit 8621096a82
10 changed files with 564 additions and 170 deletions

View File

@@ -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,

View File

@@ -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' })

View File

@@ -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;
} }

View File

@@ -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}`);
}, },
}); });

View File

@@ -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 ==========
@@ -359,8 +360,33 @@ class HRService {
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 },
@@ -385,9 +411,18 @@ class HRService {
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');
@@ -410,6 +445,19 @@ class HRService {
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;
} }
@@ -573,6 +621,16 @@ async findManagedLeaves(status?: string) {
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;
} }
@@ -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;
} }
@@ -844,6 +914,29 @@ private isSystemAdminUser(user: any) {
}); });
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({
@@ -906,6 +999,29 @@ private isSystemAdminUser(user: any) {
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;
} }
} }
@@ -961,7 +1077,19 @@ private isSystemAdminUser(user: any) {
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) {
@@ -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) {
@@ -1064,6 +1204,29 @@ 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;
} }
@@ -1074,7 +1237,18 @@ private isSystemAdminUser(user: any) {
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;
} }

View File

@@ -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' },
}); });
const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t));
const filteredTenders =
filters.status === 'EXPIRED'
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
: mappedTenders;
return { return {
tenders: tenders.map((t) => this.mapTenderExtraFields(t)), tenders: filteredTenders,
total, total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
page, page,
pageSize, pageSize,
}; };
@@ -520,15 +543,15 @@ 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],
}); });
} }
@@ -678,6 +701,16 @@ class TendersService {
return deal; return deal;
} }
private decodeUploadedFileName(fileName: string) {
if (!fileName) return 'file';
try {
return Buffer.from(fileName, 'latin1').toString('utf8');
} catch {
return fileName;
}
}
async uploadTenderAttachment( async uploadTenderAttachment(
tenderId: string, tenderId: string,
file: { path: string; originalname: string; mimetype: string; size: number }, file: { path: string; originalname: string; mimetype: string; size: number },
@@ -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 },
}) })

View File

@@ -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,6 +325,18 @@ function ContactsContent() {
<button <button
onClick={() => { onClick={() => {
resetForm() resetForm()
setCreateDefaultType('SUPPLIER')
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) 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"
@@ -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,7 +577,7 @@ 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)
}} }}

View File

@@ -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>

View File

@@ -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'
} }
@@ -159,7 +182,7 @@ 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,18 +275,73 @@ 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>
{isSupplier && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
الاختصاص <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-2 mb-3">
{supplierSpecializations.map((item) => {
const checked = formData.tags?.includes(item) || false
return (
<button
key={item}
type="button"
onClick={() => {
setFormData({
...formData,
tags: checked
? (formData.tags || []).filter((tag) => tag !== item)
: [...(formData.tags || []), item],
})
}}
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
type="button"
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-5 w-5" />
</button>
</div>
{formErrors.tags && <p className="text-red-500 text-xs mt-1">{formErrors.tags}</p>}
</div>
)}
{!isSupplier && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Rating Rating
@@ -295,6 +374,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
@@ -413,7 +493,24 @@ 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">
{isSupplier ? 'العنوان' : 'Address Information'}
</h3>
{isSupplier ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
العنوان
</label>
<input
type="text"
value={formData.address || ''}
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"
placeholder="العنوان"
/>
</div>
) : (
<div className="space-y-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">
@@ -430,9 +527,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-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</label>
City
</label>
<input <input
type="text" type="text"
value={formData.city || ''} value={formData.city || ''}
@@ -443,9 +538,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
</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>
Country
</label>
<input <input
type="text" type="text"
value={formData.country || ''} value={formData.country || ''}
@@ -456,9 +549,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
</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">Postal Code</label>
Postal Code
</label>
<input <input
type="text" type="text"
value={formData.postalCode || ''} value={formData.postalCode || ''}
@@ -469,8 +560,9 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
</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,6 +594,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
</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">Tags</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3"> <div className="space-y-3">
@@ -543,6 +637,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)} )}
</div> </div>
</div> </div>
)}
<DuplicateAlert <DuplicateAlert
email={formData.email} email={formData.email}

View File

@@ -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)