From 8621096a8287bdef43522481a73062490c760d52 Mon Sep 17 00:00:00 2001 From: Aya Date: Sun, 3 May 2026 15:25:50 +0300 Subject: [PATCH] add suppliers --- .../modules/contacts/contacts.controller.ts | 1 + .../src/modules/contacts/contacts.routes.ts | 3 +- .../src/modules/contacts/contacts.service.ts | 7 + backend/src/modules/hr/hr.routes.ts | 15 +- backend/src/modules/hr/hr.service.ts | 217 +++++++++++- .../src/modules/tenders/tenders.service.ts | 91 +++-- frontend/src/app/contacts/page.tsx | 62 +++- .../portal/managed-expense-claims/page.tsx | 11 +- .../src/components/contacts/ContactForm.tsx | 325 +++++++++++------- frontend/src/lib/api/contacts.ts | 2 + 10 files changed, 564 insertions(+), 170 deletions(-) diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts index 580a67f..987a14a 100644 --- a/backend/src/modules/contacts/contacts.controller.ts +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -29,6 +29,7 @@ class ContactsController { const filters = { search: req.query.search as string, type: req.query.type as string, + specialization: req.query.specialization as string, status: req.query.status as string, category: req.query.category as string, source: req.query.source as string, diff --git a/backend/src/modules/contacts/contacts.routes.ts b/backend/src/modules/contacts/contacts.routes.ts index f896bc5..4a68f08 100644 --- a/backend/src/modules/contacts/contacts.routes.ts +++ b/backend/src/modules/contacts/contacts.routes.ts @@ -43,7 +43,7 @@ router.post( authorize('contacts', 'contacts', 'create'), [ 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('email').optional().isEmail(), body('source').notEmpty(), @@ -73,6 +73,7 @@ router.put( 'UN', 'NGO', 'INSTITUTION', + 'SUPPLIER', ]), body('email') .optional({ values: 'falsy' }) diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index 3cd2e1a..d7a6cca 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -36,6 +36,7 @@ interface UpdateContactData extends Partial { interface SearchFilters { search?: string; type?: string; + specialization?: string; status?: string; category?: string; source?: string; @@ -148,6 +149,12 @@ class ContactsService { where.type = filters.type; } + if (filters.specialization) { + where.tags = { + has: filters.specialization, + }; + } + if (filters.status) { where.status = filters.status; } diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 8c2bb59..9089b14 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -15,11 +15,22 @@ if (!fs.existsSync(expenseClaimsUploadDir)) { fs.mkdirSync(expenseClaimsUploadDir, { recursive: true }); } +const decodeOriginalFileName = (name: string) => { + return Buffer.from(name, 'latin1').toString('utf8'); +}; + const expenseClaimStorage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir), filename: (_req, file, cb) => { - const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_'); - cb(null, `${crypto.randomUUID()}-${safeName}`); + const originalName = decodeOriginalFileName(file.originalname); + 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}`); }, }); diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index 07b0764..1bbb453 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -1,6 +1,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; +import { notificationsService } from '../notifications/notifications.service'; class HRService { // ========== EMPLOYEES ========== @@ -352,15 +353,40 @@ class HRService { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'CREATE', userId, }); + const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'leave_requests', + fallbackEmployeeId: leave.employeeId, + fallbackToManager: true, + type: 'LEAVE_REQUEST_SUBMITTED', + title: 'طلب إجازة جديد بانتظار الموافقة', + message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`, + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: leave.employeeId, + type: 'LEAVE_REQUEST_CREATED', + title: 'تم إرسال طلب الإجازة', + message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.', + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [], + }); + return leave; } + async approveLeave(id: string, approvedBy: string, userId: string) { const leave = await prisma.leave.update({ where: { id }, @@ -378,16 +404,25 @@ class HRService { const year = new Date(leave.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'APPROVE', userId, }); + await notificationsService.notifyEmployeeUser({ + employeeId: leave.employeeId, + type: 'LEAVE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الإجازة', + message: 'تمت الموافقة على طلب الإجازة الخاص بك.', + entityType: 'LEAVE', + entityId: leave.id, + excludeUserIds: [userId], + }); + return leave; } - async rejectLeave(id: string, rejectedReason: string, userId: string) { const leave = await prisma.leave.findUnique({ where: { id } }); if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found'); @@ -403,13 +438,26 @@ class HRService { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: id, action: 'REJECT', userId, reason: rejectedReason, }); + + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_REJECTED', + title: 'تم رفض طلب الإجازة', + message: rejectedReason + ? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}` + : 'تم رفض طلب الإجازة الخاص بك.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -566,13 +614,23 @@ async findManagedLeaves(status?: string) { const year = new Date(updated.startDate).getFullYear(); await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_APPROVE', userId, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الإجازة', + message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -610,7 +668,7 @@ async findManagedLeaves(status?: string) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_REJECT', @@ -618,6 +676,18 @@ async findManagedLeaves(status?: string) { reason: rejectedReason, }); + await notificationsService.notifyEmployeeUser({ + employeeId: updated.employeeId, + type: 'LEAVE_REQUEST_REJECTED', + title: 'تم رفض طلب الإجازة', + message: rejectedReason + ? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}` + : 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.', + entityType: 'LEAVE', + entityId: updated.id, + excludeUserIds: [userId], + }); + return updated; } @@ -843,7 +913,30 @@ private isSystemAdminUser(user: any) { 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; } @@ -889,7 +982,7 @@ private isSystemAdminUser(user: any) { const loanAmount = Number(loan.amount || 0); const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5; - // المرحلة الأولى: HR approval + if (loan.status === 'PENDING_HR') { if (needsAdminApproval) { const updatedLoan = await prisma.loan.update({ @@ -899,13 +992,36 @@ private isSystemAdminUser(user: any) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'HR_APPROVE_FORWARD_TO_ADMIN', userId, }); + const fullLoan = await this.findLoanById(id); + const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`; + + await notificationsService.notifyApprovalRecipients({ + resource: 'loan_requests', + type: 'LOAN_REQUEST_PENDING_ADMIN', + title: 'طلب قرض محال إلى مدير النظام', + message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`, + entityType: 'LOAN', + entityId: id, + excludeUserIds: [userId], + }); + + await notificationsService.notifyEmployeeUser({ + employeeId: fullLoan.employee.id, + type: 'LOAN_REQUEST_ESCALATED', + title: 'تمت إحالة طلب القرض للاعتماد النهائي', + message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`, + entityType: 'LOAN', + entityId: id, + excludeUserIds: [userId], + }); + return updatedLoan; } } @@ -954,14 +1070,26 @@ private isSystemAdminUser(user: any) { ), ]); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE', 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) { @@ -981,7 +1109,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', @@ -989,6 +1117,18 @@ private isSystemAdminUser(user: any) { reason: rejectedReason, }); + await notificationsService.notifyEmployeeUser({ + employeeId: loan.employeeId, + type: 'LOAN_REQUEST_REJECTED', + title: 'تم رفض طلب القرض', + message: rejectedReason?.trim() + ? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}` + : 'تم رفض طلب القرض الخاص بك.', + entityType: 'LOAN', + entityId: loan.id, + excludeUserIds: [userId], + }); + return loan; } async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { @@ -1063,7 +1203,30 @@ private isSystemAdminUser(user: any) { }, 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; } @@ -1073,8 +1236,19 @@ private isSystemAdminUser(user: any) { data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null }, include: { employee: true }, }); - await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); - return req; + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); + + await notificationsService.notifyEmployeeUser({ + employeeId: req.employeeId, + type: 'PURCHASE_REQUEST_APPROVED', + title: 'تمت الموافقة على طلب الشراء', + message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [userId], + }); + + return req;; } async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { @@ -1084,6 +1258,19 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); + + await notificationsService.notifyEmployeeUser({ + employeeId: req.employeeId, + type: 'PURCHASE_REQUEST_REJECTED', + title: 'تم رفض طلب الشراء', + message: rejectedReason?.trim() + ? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}` + : `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`, + entityType: 'PURCHASE_REQUEST', + entityId: req.id, + excludeUserIds: [userId], + }); + return req; } diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index cfe3798..91fa62e 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -1,6 +1,7 @@ import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; +import { notificationsService } from '../notifications/notifications.service'; import { Prisma } from '@prisma/client'; import path from 'path'; import fs from 'fs' @@ -208,11 +209,25 @@ class TendersService { return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; } - private mapTenderExtraFields(tender: T) { + private getComputedTenderStatus(tender: T) { + if (tender.status === 'CONVERTED_TO_DEAL' || tender.status === 'CANCELLED') { + return tender.status; + } + + if (tender.closingDate && new Date(tender.closingDate) < new Date()) { + return 'EXPIRED'; + } + + return tender.status || 'ACTIVE'; + } + + private mapTenderExtraFields(tender: T) { const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); return { ...tender, + status: this.getComputedTenderStatus(tender), + originalStatus: tender.status, notes: cleanNotes || null, initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), finalBondValue: meta.finalBondValue ?? null, @@ -345,7 +360,9 @@ class TendersService { { 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.announcementType) where.announcementType = filters.announcementType; @@ -361,9 +378,15 @@ class TendersService { }, orderBy: { createdAt: 'desc' }, }); - return { - tenders: tenders.map((t) => this.mapTenderExtraFields(t)), - total, + const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t)); + const filteredTenders = + filters.status === 'EXPIRED' + ? mappedTenders.filter((t: any) => t.status === 'EXPIRED') + : mappedTenders; + + return { + tenders: filteredTenders, + total: filters.status === 'EXPIRED' ? filteredTenders.length : total, page, pageSize, }; @@ -517,20 +540,20 @@ class TendersService { }, }); - const assignedUser = directive.assignedToEmployee?.user; - if (assignedUser?.id) { - const typeLabel = this.getDirectiveTypeLabel(data.type); - await prisma.notification.create({ - data: { - userId: assignedUser.id, - type: 'TENDER_DIRECTIVE_ASSIGNED', - title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`, - message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`, - entityType: 'TENDER_DIRECTIVE', - entityId: directive.id, - }, - }); - } + const assignedUser = directive.assignedToEmployee?.user; + if (assignedUser?.id) { + const typeLabel = this.getDirectiveTypeLabel(data.type); + + await notificationsService.notifyMany({ + userIds: [assignedUser.id], + type: 'TENDER_DIRECTIVE_ASSIGNED', + title: 'تم إسناد توجيه مناقصة جديد', + message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`, + entityType: 'TENDER', + entityId: tender.id, + excludeUserIds: [userId], + }); + } await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', @@ -678,7 +701,17 @@ class TendersService { 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, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, @@ -686,14 +719,17 @@ class TendersService { ) { const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); if (!tender) throw new AppError(404, 'Tender not found'); + const fileName = path.basename(file.path); + const originalName = this.decodeUploadedFileName(file.originalname); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER', entityId: tenderId, tenderId, fileName, - originalName: file.originalname, + originalName, mimeType: file.mimetype, size: file.size, path: file.path, @@ -701,6 +737,7 @@ class TendersService { uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, @@ -708,6 +745,7 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } @@ -721,8 +759,12 @@ class TendersService { where: { id: directiveId }, select: { id: true, tenderId: true }, }); + if (!directive) throw new AppError(404, 'Directive not found'); + const fileName = path.basename(file.path); + const originalName = this.decodeUploadedFileName(file.originalname); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER_DIRECTIVE', @@ -730,7 +772,7 @@ class TendersService { tenderDirectiveId: directiveId, tenderId: directive.tenderId, fileName, - originalName: file.originalname, + originalName, mimeType: file.mimetype, size: file.size, path: file.path, @@ -738,6 +780,7 @@ class TendersService { uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, @@ -745,9 +788,9 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } - async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, @@ -765,12 +808,10 @@ class TendersService { if (!attachment) throw new AppError(404, 'File not found') - // حذف من الديسك if (attachment.path && fs.existsSync(attachment.path)) { fs.unlinkSync(attachment.path) } - // حذف من DB await prisma.attachment.delete({ where: { id: attachmentId }, }) diff --git a/frontend/src/app/contacts/page.tsx b/frontend/src/app/contacts/page.tsx index 2867a25..c0c66d1 100644 --- a/frontend/src/app/contacts/page.tsx +++ b/frontend/src/app/contacts/page.tsx @@ -54,6 +54,8 @@ function ContactsContent() { const [searchTerm, setSearchTerm] = useState('') const [selectedType, setSelectedType] = useState('all') + const [selectedSpecialization, setSelectedSpecialization] = useState('all') + const [createDefaultType, setCreateDefaultType] = useState('INDIVIDUAL') const [selectedStatus, setSelectedStatus] = useState('all') const [selectedSource, setSelectedSource] = useState('all') const [selectedRating, setSelectedRating] = useState('all') @@ -82,6 +84,7 @@ function ContactsContent() { if (searchTerm) filters.search = searchTerm if (selectedType !== 'all') filters.type = selectedType + if (selectedSpecialization !== 'all') filters.specialization = selectedSpecialization if (selectedStatus !== 'all') filters.status = selectedStatus if (selectedSource !== 'all') filters.source = selectedSource if (selectedRating !== 'all') filters.rating = parseInt(selectedRating) @@ -97,7 +100,7 @@ function ContactsContent() { } finally { setLoading(false) } - }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) + }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) useEffect(() => { const debounce = setTimeout(() => { @@ -109,7 +112,7 @@ function ContactsContent() { useEffect(() => { fetchContacts() - }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) + }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) const handleCreate = async (data: CreateContactData) => { setSubmitting(true) @@ -247,6 +250,21 @@ function ContactsContent() { return (contact as any).nameAr || '' } + const supplierSpecializations = [ + 'كاميرات', + 'شبكات', + 'أجهزة كومبيوتر', + 'projectors', + 'مقاسم هاتفية', + ' Mobile - Tablet', + 'firewall', + 'طاقة بديلة', + 'حديد', + ' باركود - POS', + 'أجهزة منزلية', + 'تكييف وتبريد', + ] + return (
@@ -307,8 +325,20 @@ function ContactsContent() { +
- +
+ + +
{formErrors.type &&

{formErrors.type}

}
@@ -251,50 +275,106 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
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" - placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'} + placeholder={isSupplier ? 'أدخل اسم المورد' : isOrganizationType ? 'Enter contact person name' : 'Enter contact name'} /> {formErrors.name &&

{formErrors.name}

}
-
- -
- {[1, 2, 3, 4, 5].map((star) => ( - - ))} - {rating > 0 && ( + {isSupplier && ( +
+ + +
+ {supplierSpecializations.map((item) => { + const checked = formData.tags?.includes(item) || false + return ( + + ) + })} +
+ +
+ 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="أضف اختصاص آخر" + /> - )} +
+ + {formErrors.tags &&

{formErrors.tags}

}
-
+ )} + {!isSupplier && ( +
+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + {rating > 0 && ( + + )} +
+
+ )}
@@ -413,64 +493,76 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val )}
-

Address Information

-
+

+ {isSupplier ? 'العنوان' : 'Address Information'} +

+ + {isSupplier ? (
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="Street address" + placeholder="العنوان" />
- -
+ ) : ( +
setFormData({ ...formData, city: e.target.value })} + 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="City" + placeholder="Street address" />
-
- - 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" - placeholder="Country" - /> -
+
+
+ + setFormData({ ...formData, city: 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="City" + /> +
-
- - 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" - /> +
+ + 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" + placeholder="Country" + /> +
+ +
+ + 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" + /> +
-
+ )}
- + {!isSupplier && (

Categories

+ )} {isCompanyEmployeeSelected && (
@@ -501,48 +594,50 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)} -
-

Tags

-
-
- 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="Add a tag (press Enter)" - /> - -
+ {!isSupplier && ( +
+

Tags

+
+
+ 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="Add a tag (press Enter)" + /> + +
- {formData.tags && formData.tags.length > 0 && ( -
- {formData.tags.map((tag, index) => ( - - #{tag} - - - ))} -
- )} -
-
+ {formData.tags && formData.tags.length > 0 && ( +
+ {formData.tags.map((tag, index) => ( + + #{tag} + + + ))} +
+ )} +
+
+ )} { export interface ContactFilters { search?: string type?: string + specialization?: string status?: string category?: string source?: string @@ -87,6 +88,7 @@ export const contactsAPI = { const params = new URLSearchParams() if (filters.search) params.append('search', filters.search) 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.category) params.append('category', filters.category) if (filters.source) params.append('source', filters.source)