diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts index 987a14a..5b6f9e5 100644 --- a/backend/src/modules/contacts/contacts.controller.ts +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -29,13 +29,13 @@ 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, rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined, createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined, + excludeSuppliers: req.query.excludeSuppliers === 'true', }; const result = await contactsService.findAll(filters, page, pageSize); @@ -242,6 +242,7 @@ class ContactsController { category: req.query.category as string, rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true', + excludeSuppliers: req.query.excludeSuppliers === 'true', }; const buffer = await contactsService.export(filters); diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index d7a6cca..17ad3e1 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -36,7 +36,6 @@ interface UpdateContactData extends Partial { interface SearchFilters { search?: string; type?: string; - specialization?: string; status?: string; category?: string; source?: string; @@ -44,6 +43,7 @@ interface SearchFilters { createdFrom?: Date; createdTo?: Date; excludeCompanyEmployees?: boolean; + excludeSuppliers?: boolean; } class ContactsService { @@ -149,12 +149,6 @@ class ContactsService { where.type = filters.type; } - if (filters.specialization) { - where.tags = { - has: filters.specialization, - }; - } - if (filters.status) { where.status = filters.status; } @@ -173,6 +167,22 @@ class ContactsService { }; } + if (filters.excludeSuppliers) { + where.NOT = [ + { type: 'SUPPLIER' }, + { + categories: { + some: { + OR: [ + { name: { in: ['Supplier', 'Suppliers'] } }, + { nameAr: { contains: 'مورد' } }, + ], + }, + }, + }, + ]; + } + if (filters.createdFrom || filters.createdTo) { where.createdAt = {}; if (filters.createdFrom) { @@ -765,6 +775,7 @@ class ContactsService { const where: Prisma.ContactWhereInput = { status: { not: 'DELETED' }, }; + const notConditions: Prisma.ContactWhereInput[] = []; if (filters.search) { where.OR = [ @@ -779,19 +790,39 @@ class ContactsService { if (filters.source) where.source = filters.source; if (filters.rating) where.rating = filters.rating; + if (filters.excludeSuppliers) { + notConditions.push( + { type: 'SUPPLIER' }, + { + categories: { + some: { + OR: [ + { name: { in: ['Supplier', 'Suppliers'] } }, + { nameAr: { contains: 'مورد' } }, + ], + }, + }, + } + ); + } + if (filters.excludeCompanyEmployees) { const companyEmployeeCategory = await prisma.contactCategory.findFirst({ where: { name: 'Company Employee', isActive: true }, }); if (companyEmployeeCategory) { - where.NOT = { + notConditions.push({ categories: { some: { id: companyEmployeeCategory.id }, }, - }; + }); } } + if (notConditions.length > 0) { + where.NOT = notConditions; + } + // Fetch all contacts (no pagination for export) const contacts = await prisma.contact.findMany({ where, diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index 9089b14..8c2bb59 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -15,22 +15,11 @@ 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 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}`); + const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${crypto.randomUUID()}-${safeName}`); }, }); diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index 1bbb453..07b0764 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -1,7 +1,6 @@ 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 ========== @@ -353,40 +352,15 @@ 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 }, @@ -404,25 +378,16 @@ 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'); @@ -438,26 +403,13 @@ 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; } @@ -614,23 +566,13 @@ 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; } @@ -668,7 +610,7 @@ async findManagedLeaves(status?: string) { }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LEAVE', entityId: updated.id, action: 'MANAGER_REJECT', @@ -676,18 +618,6 @@ 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; } @@ -913,30 +843,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - 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: [], - }); - + await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); return loan; } @@ -982,7 +889,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({ @@ -992,36 +899,13 @@ 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; } } @@ -1070,26 +954,14 @@ private isSystemAdminUser(user: any) { ), ]); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE', userId, }); - 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; + return this.findLoanById(id); } async rejectLoan(id: string, rejectedReason: string, userId: string) { @@ -1109,7 +981,7 @@ private isSystemAdminUser(user: any) { include: { employee: true }, }); - await AuditLogger.log({ + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', @@ -1117,18 +989,6 @@ 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) { @@ -1203,30 +1063,7 @@ private isSystemAdminUser(user: any) { }, include: { employee: true }, }); - 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: [], - }); - + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); return req; } @@ -1236,19 +1073,8 @@ 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 }); - - await notificationsService.notifyEmployeeUser({ - employeeId: req.employeeId, - type: 'PURCHASE_REQUEST_APPROVED', - title: 'تمت الموافقة على طلب الشراء', - message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`, - entityType: 'PURCHASE_REQUEST', - entityId: req.id, - excludeUserIds: [userId], - }); - - return req;; + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); + return req; } async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { @@ -1258,19 +1084,6 @@ 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/suppliers/suppliers.controller.ts b/backend/src/modules/suppliers/suppliers.controller.ts new file mode 100644 index 0000000..a058d4a --- /dev/null +++ b/backend/src/modules/suppliers/suppliers.controller.ts @@ -0,0 +1,89 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; +import { suppliersService } from './suppliers.service'; + +class SuppliersController { + async findAll(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 20; + const filters = { + search: req.query.search as string, + status: req.query.status as string, + rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, + category: req.query.category as string, + }; + + const result = await suppliersService.findAll(filters, page, pageSize); + res.json(ResponseFormatter.paginated(result.suppliers, result.total, result.page, result.pageSize)); + } catch (error) { + next(error); + } + } + + async getStats(req: AuthRequest, res: Response, next: NextFunction) { + try { + const stats = await suppliersService.getStats(); + res.json(ResponseFormatter.success(stats)); + } catch (error) { + next(error); + } + } + + async findById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const supplier = await suppliersService.findById(req.params.id); + res.json(ResponseFormatter.success(supplier)); + } catch (error) { + next(error); + } + } + + async create(req: AuthRequest, res: Response, next: NextFunction) { + try { + const supplier = await suppliersService.create({ ...req.body, createdById: req.user!.id }, req.user!.id); + res.status(201).json(ResponseFormatter.success(supplier, 'تم إنشاء المورد بنجاح - Supplier created successfully')); + } catch (error) { + next(error); + } + } + + async update(req: AuthRequest, res: Response, next: NextFunction) { + try { + const supplier = await suppliersService.update(req.params.id, req.body, req.user!.id); + res.json(ResponseFormatter.success(supplier, 'تم تحديث المورد بنجاح - Supplier updated successfully')); + } catch (error) { + next(error); + } + } + + async archive(req: AuthRequest, res: Response, next: NextFunction) { + try { + const supplier = await suppliersService.archive(req.params.id, req.user!.id, req.body.reason); + res.json(ResponseFormatter.success(supplier, 'تم أرشفة المورد بنجاح - Supplier archived successfully')); + } catch (error) { + next(error); + } + } + + async export(req: AuthRequest, res: Response, next: NextFunction) { + try { + const filters = { + search: req.query.search as string, + status: req.query.status as string, + rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, + category: req.query.category as string, + }; + + const buffer = await suppliersService.export(filters); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=suppliers-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } + } +} + +export const suppliersController = new SuppliersController(); diff --git a/backend/src/modules/suppliers/suppliers.routes.ts b/backend/src/modules/suppliers/suppliers.routes.ts new file mode 100644 index 0000000..012e41f --- /dev/null +++ b/backend/src/modules/suppliers/suppliers.routes.ts @@ -0,0 +1,66 @@ +import { Router } from 'express'; +import { body, param } from 'express-validator'; +import { authenticate, authorize } from '../../shared/middleware/auth'; +import { validate } from '../../shared/middleware/validation'; +import { suppliersController } from './suppliers.controller'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', authorize('contacts', 'contacts', 'read'), suppliersController.findAll); +router.get('/stats', authorize('contacts', 'contacts', 'read'), suppliersController.getStats); +router.get('/export', authorize('contacts', 'contacts', 'read'), suppliersController.export); + +router.get( + '/:id', + authorize('contacts', 'contacts', 'read'), + param('id').isUUID(), + validate, + suppliersController.findById +); + +router.post( + '/', + authorize('contacts', 'contacts', 'create'), + [ + body('name').optional({ values: 'falsy' }).trim(), + body('companyName').optional({ values: 'falsy' }).trim(), + body('email') + .optional({ values: 'falsy' }) + .custom((value) => { + if (value === null || value === undefined || value === '') return true; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + }) + .withMessage('Invalid email format'), + validate, + ], + suppliersController.create +); + +router.put( + '/:id', + authorize('contacts', 'contacts', 'update'), + [ + param('id').isUUID(), + body('email') + .optional({ values: 'falsy' }) + .custom((value) => { + if (value === null || value === undefined || value === '') return true; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + }) + .withMessage('Invalid email format'), + validate, + ], + suppliersController.update +); + +router.post( + '/:id/archive', + authorize('contacts', 'contacts', 'archive'), + param('id').isUUID(), + validate, + suppliersController.archive +); + +export default router; diff --git a/backend/src/modules/suppliers/suppliers.service.ts b/backend/src/modules/suppliers/suppliers.service.ts new file mode 100644 index 0000000..d874d6e --- /dev/null +++ b/backend/src/modules/suppliers/suppliers.service.ts @@ -0,0 +1,314 @@ +import prisma from '../../config/database'; +import { Prisma } from '@prisma/client'; +import { AppError } from '../../shared/middleware/errorHandler'; +import { contactsService } from '../contacts/contacts.service'; + +interface SupplierFilters { + search?: string; + status?: string; + rating?: number; + category?: string; +} + +interface SupplierContactData { + name?: string; + nameAr?: string; + email?: string; + phone?: string; + mobile?: string; + website?: string; + companyName?: string; + companyNameAr?: string; + taxNumber?: string; + commercialRegister?: string; + address?: string; + city?: string; + country?: string; + postalCode?: string; + categories?: string[]; + tags?: string[]; + source?: string; + rating?: number; + status?: string; + customFields?: any; + createdById?: string; +} + +class SuppliersService { + private supplierCategoryNames = ['Supplier', 'Suppliers']; + + private isSupplierSystemCategory(category: any) { + return ( + this.supplierCategoryNames.includes(category?.name) || + Boolean(category?.nameAr && String(category.nameAr).includes('مورد')) + ); + } + + private getSupplierCategoryLabels(supplier: any): string[] { + const customFields = supplier.customFields || {}; + + if (Array.isArray(customFields.supplierCategories)) { + return customFields.supplierCategories + .map((category: any) => String(category || '').trim()) + .filter(Boolean); + } + + if (customFields.supplierCategory) return [String(customFields.supplierCategory)]; + + return (supplier.categories || []) + .filter((category: any) => !this.isSupplierSystemCategory(category)) + .map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name) + .filter(Boolean); + } + + private async ensureSupplierCategory() { + const existing = await prisma.contactCategory.findFirst({ + where: { + isActive: true, + OR: [ + { name: { in: this.supplierCategoryNames } }, + { nameAr: { contains: 'مورد' } }, + ], + }, + }); + + if (existing) return existing; + + return prisma.contactCategory.create({ + data: { + name: 'Supplier', + nameAr: 'مورّد', + description: 'Supplier / vendor records managed from Supplier Management module', + }, + }); + } + + private supplierCondition(): Prisma.ContactWhereInput { + return { + OR: [ + { type: 'SUPPLIER' }, + { + categories: { + some: { + OR: [ + { name: { in: this.supplierCategoryNames } }, + { nameAr: { contains: 'مورد' } }, + ], + }, + }, + }, + ], + }; + } + + private buildWhere(filters: SupplierFilters = {}): Prisma.ContactWhereInput { + const andConditions: Prisma.ContactWhereInput[] = [ + { archivedAt: null }, + this.supplierCondition(), + ]; + + if (filters.search) { + andConditions.push({ + OR: [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { nameAr: { contains: filters.search, mode: 'insensitive' } }, + { email: { contains: filters.search, mode: 'insensitive' } }, + { phone: { contains: filters.search } }, + { mobile: { contains: filters.search } }, + { companyName: { contains: filters.search, mode: 'insensitive' } }, + { companyNameAr: { contains: filters.search, mode: 'insensitive' } }, + { taxNumber: { contains: filters.search, mode: 'insensitive' } }, + { commercialRegister: { contains: filters.search, mode: 'insensitive' } }, + ], + }); + } + + if (filters.status) andConditions.push({ status: filters.status }); + if (filters.rating !== undefined) andConditions.push({ rating: filters.rating }); + if (filters.category) { + andConditions.push({ + OR: [ + { + customFields: { + path: ['supplierCategories'], + array_contains: [filters.category], + } as any, + }, + { + customFields: { + path: ['supplierCategory'], + equals: filters.category, + } as any, + }, + { + categories: { + some: { + OR: [ + { name: { equals: filters.category, mode: 'insensitive' } }, + { nameAr: { equals: filters.category } }, + ], + }, + }, + }, + ], + }); + } + + return { AND: andConditions }; + } + + private isSupplierContact(contact: any) { + return ( + contact.type === 'SUPPLIER' || + contact.categories?.some((category: any) => this.isSupplierSystemCategory(category)) + ); + } + + async findAll(filters: SupplierFilters, page: number = 1, pageSize: number = 20) { + const skip = (page - 1) * pageSize; + const where = this.buildWhere(filters); + + const [total, suppliers] = await Promise.all([ + prisma.contact.count({ where }), + prisma.contact.findMany({ + where, + skip, + take: pageSize, + include: { + categories: true, + parent: { select: { id: true, name: true, type: true } }, + createdBy: { select: { id: true, email: true, username: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + return { suppliers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; + } + + async findById(id: string) { + const supplier = await prisma.contact.findUnique({ + where: { id }, + include: { + categories: true, + parent: true, + createdBy: { select: { id: true, email: true, username: true } }, + }, + }); + + if (!supplier || supplier.archivedAt || !this.isSupplierContact(supplier)) { + throw new AppError(404, 'المورد غير موجود - Supplier not found'); + } + + return supplier; + } + + async create(data: SupplierContactData, userId: string) { + const supplierCategory = await this.ensureSupplierCategory(); + const categories = [supplierCategory.id]; + + return contactsService.create( + { + ...data, + type: 'SUPPLIER', + name: data.name || data.companyName || 'Supplier', + companyName: data.companyName || data.name, + country: data.country || 'Syria', + source: data.source || 'SUPPLIER_MODULE', + categories, + createdById: data.createdById || userId, + }, + userId + ); + } + + async update(id: string, data: SupplierContactData, userId: string) { + const existing = await this.findById(id); + const supplierCategory = await this.ensureSupplierCategory(); + const categories = [supplierCategory.id]; + + return contactsService.update( + id, + { + ...data, + type: 'SUPPLIER', + name: data.name || data.companyName || existing.name, + companyName: data.companyName || data.name || existing.companyName || undefined, + categories, + }, + userId + ); + } + + async archive(id: string, userId: string, reason?: string) { + await this.findById(id); + return contactsService.archive(id, userId, reason || 'Archived from Supplier Management'); + } + + async getStats() { + const [total, active, inactive, blocked] = await Promise.all([ + prisma.contact.count({ where: this.buildWhere() }), + prisma.contact.count({ where: this.buildWhere({ status: 'ACTIVE' }) }), + prisma.contact.count({ where: this.buildWhere({ status: 'INACTIVE' }) }), + prisma.contact.count({ where: this.buildWhere({ status: 'BLOCKED' }) }), + ]); + return { total, active, inactive, blocked }; + } + + async export(filters: SupplierFilters): Promise { + const xlsx = require('xlsx'); + const suppliers = await prisma.contact.findMany({ + where: this.buildWhere(filters), + include: { + categories: true, + createdBy: { select: { username: true, email: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const exportData = suppliers.map((supplier) => { + const customFields: any = supplier.customFields || {}; + const categoryNames = this.getSupplierCategoryLabels(supplier).join(', '); + + return { + 'Supplier ID': supplier.uniqueContactId, + 'Supplier Code': customFields.supplierCode || '', + 'Supplier Name': supplier.companyName || supplier.name, + 'Supplier Name (Arabic)': supplier.companyNameAr || supplier.nameAr || '', + 'Contact Person': supplier.name || '', + 'Email': supplier.email || '', + 'Phone': supplier.phone || '', + 'Mobile': supplier.mobile || '', + 'Website': supplier.website || '', + 'Tax Number': supplier.taxNumber || '', + 'Commercial Register': supplier.commercialRegister || '', + 'Categories': categoryNames, + 'Payment Terms': customFields.paymentTerms || '', + 'Bank Name': customFields.bankName || '', + 'Bank Account': customFields.bankAccount || '', + 'Address': supplier.address || '', + 'City': supplier.city || '', + 'Country': supplier.country || '', + 'Status': supplier.status, + 'Rating': supplier.rating || '', + 'Notes': customFields.notes || '', + 'Created By': supplier.createdBy?.username || supplier.createdBy?.email || '', + 'Created At': supplier.createdAt.toISOString(), + }; + }); + + const worksheet = xlsx.utils.json_to_sheet(exportData); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Suppliers'); + worksheet['!cols'] = [ + { wch: 16 }, { wch: 18 }, { wch: 28 }, { wch: 28 }, { wch: 24 }, { wch: 30 }, + { wch: 16 }, { wch: 16 }, { wch: 24 }, { wch: 18 }, { wch: 22 }, { wch: 18 }, + { wch: 18 }, { wch: 22 }, { wch: 26 }, { wch: 30 }, { wch: 16 }, { wch: 16 }, + { wch: 12 }, { wch: 12 }, { wch: 30 }, { wch: 18 }, { wch: 24 }, + ]; + + return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + } +} + +export const suppliersService = new SuppliersService(); diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 91fa62e..cfe3798 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -1,7 +1,6 @@ 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' @@ -209,25 +208,11 @@ class TendersService { return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; } - 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) { + 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, @@ -360,9 +345,7 @@ class TendersService { { issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, ]; } - if (filters.status && filters.status !== 'EXPIRED') { - where.status = filters.status; - } + if (filters.status) where.status = filters.status; if (filters.source) where.source = filters.source; if (filters.announcementType) where.announcementType = filters.announcementType; @@ -378,15 +361,9 @@ class TendersService { }, 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 { - tenders: filteredTenders, - total: filters.status === 'EXPIRED' ? filteredTenders.length : total, + return { + tenders: tenders.map((t) => this.mapTenderExtraFields(t)), + total, page, pageSize, }; @@ -540,20 +517,20 @@ class TendersService { }, }); - 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], - }); - } + 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, + }, + }); + } await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', @@ -701,17 +678,7 @@ class TendersService { 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, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, @@ -719,17 +686,14 @@ 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, + originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, @@ -737,7 +701,6 @@ class TendersService { uploadedBy: userId, }, }); - await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, @@ -745,7 +708,6 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); - return attachment; } @@ -759,12 +721,8 @@ 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', @@ -772,7 +730,7 @@ class TendersService { tenderDirectiveId: directiveId, tenderId: directive.tenderId, fileName, - originalName, + originalName: file.originalname, mimeType: file.mimetype, size: file.size, path: file.path, @@ -780,7 +738,6 @@ class TendersService { uploadedBy: userId, }, }); - await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, @@ -788,9 +745,9 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); - return attachment; } + async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, @@ -808,10 +765,12 @@ 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/backend/src/routes/index.ts b/backend/src/routes/index.ts index dfc403c..62c4a28 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -10,6 +10,7 @@ import projectsRoutes from '../modules/projects/projects.routes'; import marketingRoutes from '../modules/marketing/marketing.routes'; import tendersRoutes from '../modules/tenders/tenders.routes'; import notificationsRoutes from '../modules/notifications/notifications.routes'; +import suppliersRoutes from '../modules/suppliers/suppliers.routes'; const router = Router(); @@ -18,6 +19,7 @@ router.use('/admin', adminRoutes); router.use('/dashboard', dashboardRoutes); router.use('/auth', authRoutes); router.use('/contacts', contactsRoutes); +router.use('/suppliers', suppliersRoutes); router.use('/crm', crmRoutes); router.use('/hr', hrRoutes); router.use('/inventory', inventoryRoutes); @@ -35,6 +37,7 @@ router.get('/', (req, res) => { modules: [ 'Auth', 'Contact Management', + 'Supplier Management', 'CRM', 'HR Management', 'Inventory & Assets', diff --git a/frontend/src/app/contacts/[id]/page.tsx b/frontend/src/app/contacts/[id]/page.tsx index fb5c599..5653fd5 100644 --- a/frontend/src/app/contacts/[id]/page.tsx +++ b/frontend/src/app/contacts/[id]/page.tsx @@ -106,7 +106,8 @@ function ContactDetailContent() { SCHOOL: 'bg-yellow-100 text-yellow-700', UN: 'bg-sky-100 text-sky-700', NGO: 'bg-pink-100 text-pink-700', - INSTITUTION: 'bg-gray-100 text-gray-700' + INSTITUTION: 'bg-gray-100 text-gray-700', + SUPPLIER: 'bg-emerald-100 text-emerald-700' } return colors[type] || 'bg-gray-100 text-gray-700' } @@ -124,7 +125,8 @@ function ContactDetailContent() { SCHOOL: 'مدارس - Schools', UN: 'UN - United Nations', NGO: 'NGO - Non-Governmental Organization', - INSTITUTION: 'مؤسسة - Institution' + INSTITUTION: 'مؤسسة - Institution', + SUPPLIER: 'مورّد - Supplier' } return labels[type] || type } @@ -370,7 +372,7 @@ function ContactDetailContent() { { id: 'address', label: 'Address', icon: MapPin }, { id: 'categories', label: 'Categories & Tags', icon: Tag }, { id: 'relationships', label: 'Relationships', icon: Users }, - ...((contact.type === 'COMPANY' || contact.type === 'HOLDING') + ...((['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) ? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }] : [] ), @@ -646,7 +648,7 @@ function ContactDetailContent() { )} {/* Hierarchy Tab */} - {activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && ( + {activeTab === 'hierarchy' && (['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) && (
diff --git a/frontend/src/app/contacts/page.tsx b/frontend/src/app/contacts/page.tsx index c0c66d1..50a47f2 100644 --- a/frontend/src/app/contacts/page.tsx +++ b/frontend/src/app/contacts/page.tsx @@ -29,6 +29,7 @@ import { } from 'lucide-react' import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts' import { categoriesAPI, Category } from '@/lib/api/categories' +import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories' import ContactForm from '@/components/contacts/ContactForm' import ContactImport from '@/components/contacts/ContactImport' @@ -54,8 +55,6 @@ 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') @@ -80,11 +79,11 @@ function ContactsContent() { const filters: ContactFilters = { page: currentPage, pageSize, + excludeSuppliers: true, } 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) @@ -100,7 +99,7 @@ function ContactsContent() { } finally { setLoading(false) } - }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) + }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) useEffect(() => { const debounce = setTimeout(() => { @@ -112,7 +111,7 @@ function ContactsContent() { useEffect(() => { fetchContacts() - }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) + }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) const handleCreate = async (data: CreateContactData) => { setSubmitting(true) @@ -195,7 +194,8 @@ function ContactsContent() { SCHOOL: 'bg-yellow-100 text-yellow-700', UN: 'bg-sky-100 text-sky-700', NGO: 'bg-pink-100 text-pink-700', - INSTITUTION: 'bg-gray-100 text-gray-700' + INSTITUTION: 'bg-gray-100 text-gray-700', + SUPPLIER: 'bg-emerald-100 text-emerald-700' } return colors[type] || 'bg-gray-100 text-gray-700' } @@ -217,7 +217,8 @@ function ContactsContent() { SCHOOL: 'مدارس', UN: 'UN', NGO: 'NGO', - INSTITUTION: 'مؤسسة' + INSTITUTION: 'مؤسسة', + SUPPLIER: 'مورّد' } return labels[type] || type } @@ -234,6 +235,7 @@ function ContactsContent() { 'UN', 'NGO', 'INSTITUTION', + 'SUPPLIER', ]) const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type) @@ -250,21 +252,6 @@ function ContactsContent() { return (contact as any).nameAr || '' } - const supplierSpecializations = [ - 'كاميرات', - 'شبكات', - 'أجهزة كومبيوتر', - 'projectors', - 'مقاسم هاتفية', - ' Mobile - Tablet', - 'firewall', - 'طاقة بديلة', - 'حديد', - ' باركود - POS', - 'أجهزة منزلية', - 'تكييف وتبريد', - ] - return (
@@ -325,20 +312,8 @@ function ContactsContent() { -
-
- - -
+
@@ -541,7 +500,6 @@ function ContactsContent() { setSelectedRating('all') setSelectedCategory('all') 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" > @@ -577,8 +535,8 @@ function ContactsContent() { onClick={() => setShowCreateModal(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > - {createDefaultType === 'SUPPLIER' ? 'إضافة مورد' : 'Create New Contact'} - + Create First Contact + ) : ( <> @@ -781,8 +739,7 @@ function ContactsContent() { size="xl" > { await handleCreate(data as CreateContactData) }} @@ -870,6 +827,7 @@ function ContactsContent() { if (selectedStatus !== 'all') filters.status = selectedStatus if (selectedCategory !== 'all') filters.category = selectedCategory if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true + filters.excludeSuppliers = true const blob = await contactsAPI.export(filters) const url = window.URL.createObjectURL(blob) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 5eab66b..4d44ce8 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -22,7 +22,8 @@ import { Settings, Bell, Shield, - FileText + FileText, + Truck } from 'lucide-react' import { dashboardAPI, notificationsAPI } from '@/lib/api' import { portalAPI } from '@/lib/api/portal' @@ -254,7 +255,17 @@ function DashboardContent() { icon: Users, color: 'bg-blue-500', href: '/contacts', - description: 'إدارة العملاء والموردين وجهات الاتصال', + description: 'إدارة العملاء وجهات الاتصال', + permission: 'contacts' + }, + { + id: 'suppliers', + name: 'إدارة الموردين', + nameEn: 'Supplier Management', + icon: Truck, + color: 'bg-emerald-500', + href: '/suppliers', + description: 'إدارة الموردين وبيانات التواصل والاعتماد', permission: 'contacts' }, { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1c19d40..495e1cf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -11,6 +11,7 @@ import { TrendingUp, Package, CheckSquare, + Truck, LogIn } from 'lucide-react' @@ -39,7 +40,12 @@ export default function Home() { { icon: Users, title: 'إدارة جهات الاتصال', - description: 'نظام شامل لإدارة العملاء والموردين وجهات الاتصال' + description: 'نظام شامل لإدارة العملاء وجهات الاتصال' + }, + { + icon: Truck, + title: 'إدارة الموردين', + description: 'وحدة مستقلة لإدارة الموردين وبيانات الاعتماد والتواصل' }, { icon: TrendingUp, diff --git a/frontend/src/app/portal/managed-expense-claims/page.tsx b/frontend/src/app/portal/managed-expense-claims/page.tsx index 8de5c72..e0d70ce 100644 --- a/frontend/src/app/portal/managed-expense-claims/page.tsx +++ b/frontend/src/app/portal/managed-expense-claims/page.tsx @@ -337,14 +337,15 @@ export default function ManagedExpenseClaimsPage() {
{claim.attachments.map((attachment) => ( - + ))}
diff --git a/frontend/src/app/suppliers/[id]/page.tsx b/frontend/src/app/suppliers/[id]/page.tsx new file mode 100644 index 0000000..8c07f3d --- /dev/null +++ b/frontend/src/app/suppliers/[id]/page.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { toast } from 'react-hot-toast' +import { ArrowLeft, Building2, Calendar, CircleDollarSign, Copy, FileText, Globe, Landmark, Mail, MapPin, Phone, Star, Tag, Truck, XCircle } from 'lucide-react' +import ProtectedRoute from '@/components/ProtectedRoute' +import LoadingSpinner from '@/components/LoadingSpinner' +import { suppliersAPI, Supplier } from '@/lib/api/suppliers' +import { isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories' + +function renderStars(rating?: number) { + if (!rating) return بدون تقييم + return
{[1, 2, 3, 4, 5].map((star) => )}
+} + +function Field({ label, value, mono = false }: { label: string; value?: any; mono?: boolean }) { + if (!value) return null + return
{label}
{value}
+} + +function getSupplierCategoryLabels(supplier: Supplier): string[] { + const customFields = supplier.customFields || {} + + if (Array.isArray(customFields.supplierCategories)) { + const categories = uniqueSupplierCategories(customFields.supplierCategories) + if (categories.length > 0) return categories + } + + if (customFields.supplierCategory) return [String(customFields.supplierCategory)] + + return uniqueSupplierCategories((supplier.categories || []) + .filter((category: any) => !isSupplierSystemCategoryName(category.name, category.nameAr)) + .map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)) +} + +function isSupplierSystemCategory(category: any) { + const name = String(category?.name || '').trim().toLowerCase() + const nameAr = String(category?.nameAr || '') + return name === 'supplier' || name === 'suppliers' || nameAr.includes('مورد') +} + +function getCategoryLabels(supplier: Supplier) { + const categoryNames = (supplier.categories || []) + .filter((category: any) => !isSupplierSystemCategory(category)) + .map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name) + .filter(Boolean) + + if (categoryNames.length > 0) return categoryNames + return supplier.customFields?.supplierCategory ? [supplier.customFields.supplierCategory] : [] +} + +function CategoryBadges({ labels }: { labels: string[] }) { + if (labels.length === 0) return بدون تصنيف + + return ( +
+ {labels.map((label) => ( + + + {label} + + ))} +
+ ) +} + +function SupplierDetailContent() { + const params = useParams() + const supplierId = params.id as string + const [supplier, setSupplier] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [copiedField, setCopiedField] = useState(null) + + useEffect(() => { + const fetchSupplier = async () => { + setLoading(true) + setError(null) + try { setSupplier(await suppliersAPI.getById(supplierId)) } + catch (err: any) { const message = err.response?.data?.message || 'Failed to load supplier'; setError(message); toast.error(message) } + finally { setLoading(false) } + } + fetchSupplier() + }, [supplierId]) + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text) + setCopiedField(field) + toast.success(`${field} copied`) + setTimeout(() => setCopiedField(null), 1800) + } + + if (loading) return
+ if (error || !supplier) return

Supplier Not Found

{error || 'This supplier does not exist'}

Back to Suppliers
+ + const customFields = supplier.customFields || {} + const supplierName = supplier.companyName || supplier.name + const contactPerson = supplier.name && supplier.name !== supplierName ? supplier.name : '' + const supplierCategoryLabels = getSupplierCategoryLabels(supplier) + const categoryLabels = supplierCategoryLabels + + return ( +
+
+
+
+
+ +

{supplierName}

{supplier.status}

Supplier Management • {supplier.uniqueContactId}

+
+
+ +
+
+ +
+
+
{supplierName.charAt(0).toUpperCase()}

{supplierName}

{supplier.companyNameAr &&

{supplier.companyNameAr}

}{customFields.supplierCode &&

{customFields.supplierCode}

}
{renderStars(supplier.rating)}
{supplier.email && }{(supplier.phone || supplier.mobile) && }{supplier.website && {supplier.website}}
Created: {new Date(supplier.createdAt).toLocaleDateString()}
Categories
+
+
Supplier Categories
+
{!supplier.taxNumber && !supplier.commercialRegister && !customFields.paymentTerms && !customFields.bankName && !customFields.bankAccount &&

No financial information available

}
+
{customFields.notes &&

Notes

{customFields.notes}

}
+
+
+
+
+ ) +} + +function InfoCard({ icon: Icon, title, children }: { icon: any; title: string; children: ReactNode }) { + return

{title}

{children}
+} + +export default function SupplierDetailPage() { + return +} diff --git a/frontend/src/app/suppliers/page.tsx b/frontend/src/app/suppliers/page.tsx new file mode 100644 index 0000000..6ac759e --- /dev/null +++ b/frontend/src/app/suppliers/page.tsx @@ -0,0 +1,323 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { FormEvent } from 'react' +import Link from 'next/link' +import { toast } from 'react-hot-toast' +import { ArrowLeft, BadgeCheck, Building2, CircleDollarSign, Download, Edit, Eye, Filter, Landmark, Loader2, Mail, Phone, Plus, Search, Star, Tag, Trash2, Truck, X } from 'lucide-react' +import ProtectedRoute from '@/components/ProtectedRoute' +import LoadingSpinner from '@/components/LoadingSpinner' +import Modal from '@/components/Modal' +import SupplierCategorySelector from '@/components/suppliers/SupplierCategorySelector' +import { suppliersAPI, Supplier, SupplierFilters, CreateSupplierData, UpdateSupplierData, SupplierStats } from '@/lib/api/suppliers' +import { DEFAULT_SUPPLIER_CATEGORIES, isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories' + +type SupplierFormState = { + companyName: string + companyNameAr: string + name: string + email: string + phone: string + mobile: string + website: string + taxNumber: string + commercialRegister: string + address: string + city: string + country: string + supplierCode: string + supplierCategories: string[] + paymentTerms: string + bankName: string + bankAccount: string + contactPosition: string + notes: string + tags: string + rating: number + status: string +} + +function getSupplierCategoryLabels(supplier: Supplier): string[] { + const customFields = supplier.customFields || {} + if (Array.isArray(customFields.supplierCategories)) { + const categories = uniqueSupplierCategories(customFields.supplierCategories) + if (categories.length > 0) return categories + } + if (customFields.supplierCategory) return [String(customFields.supplierCategory)] + return uniqueSupplierCategories((supplier.categories || []) + .filter((category: any) => !isSupplierSystemCategoryName(category.name, category.nameAr)) + .map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)) +} + +const buildInitialForm = (supplier?: Supplier): SupplierFormState => { + const customFields = supplier?.customFields || {} + return { + companyName: supplier?.companyName || supplier?.name || '', + companyNameAr: supplier?.companyNameAr || '', + name: supplier?.name || '', + email: supplier?.email || '', + phone: supplier?.phone || '', + mobile: supplier?.mobile || '', + website: supplier?.website || '', + taxNumber: supplier?.taxNumber || '', + commercialRegister: supplier?.commercialRegister || '', + address: supplier?.address || '', + city: supplier?.city || '', + country: supplier?.country || 'Syria', + supplierCode: customFields.supplierCode || '', + supplierCategories: supplier ? getSupplierCategoryLabels(supplier) : [], + paymentTerms: customFields.paymentTerms || '', + bankName: customFields.bankName || '', + bankAccount: customFields.bankAccount || '', + contactPosition: customFields.contactPosition || '', + notes: customFields.notes || '', + tags: supplier?.tags?.join(', ') || '', + rating: supplier?.rating || 0, + status: supplier?.status || 'ACTIVE', + } +} + +function renderRating(rating?: number) { + if (!rating) return بدون تقييم + return
{[1, 2, 3, 4, 5].map((star) => )}
+} + +function getSupplierName(supplier: Supplier) { + return supplier.companyName || supplier.name || '-' +} + +function getContactPerson(supplier: Supplier) { + const supplierName = getSupplierName(supplier) + if (!supplier.name || supplier.name === supplierName) return '-' + return supplier.name +} + +function SupplierForm({ supplier, submitting, availableCategories, onCancel, onSubmit }: { + supplier?: Supplier + submitting?: boolean + availableCategories?: string[] + onCancel: () => void + onSubmit: (data: any) => Promise +}) { + const isEdit = !!supplier + const [form, setForm] = useState(buildInitialForm(supplier)) + const [formErrors, setFormErrors] = useState>({}) + + useEffect(() => { + setForm(buildInitialForm(supplier)) + setFormErrors({}) + }, [supplier]) + + const updateField = (field: keyof SupplierFormState, value: string | number) => setForm((prev) => ({ ...prev, [field]: value })) + const optional = (value: string) => value.trim() || undefined + const validate = () => { + const errors: Record = {} + if (!form.companyName.trim() && !form.name.trim()) errors.companyName = 'اسم المورد أو مسؤول التواصل مطلوب' + if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'صيغة البريد الإلكتروني غير صحيحة' + setFormErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!validate()) return + const companyName = form.companyName.trim() || form.name.trim() + const contactName = form.name.trim() || companyName + const supplierCategories = uniqueSupplierCategories(form.supplierCategories) + const payload: any = { + name: contactName, + companyName, + companyNameAr: optional(form.companyNameAr), + email: optional(form.email), + phone: optional(form.phone), + mobile: optional(form.mobile), + website: optional(form.website), + taxNumber: optional(form.taxNumber), + commercialRegister: optional(form.commercialRegister), + address: optional(form.address), + city: optional(form.city), + country: form.country.trim() || 'Syria', + source: 'SUPPLIER_MODULE', + rating: form.rating || undefined, + tags: form.tags.split(',').map((tag) => tag.trim()).filter(Boolean), + customFields: { + supplierCode: form.supplierCode.trim(), + supplierCategories, + supplierCategory: supplierCategories[0] || '', + paymentTerms: form.paymentTerms.trim(), + bankName: form.bankName.trim(), + bankAccount: form.bankAccount.trim(), + contactPosition: form.contactPosition.trim(), + notes: form.notes.trim(), + }, + } + if (isEdit) payload.status = form.status + await onSubmit(payload) + } + + const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900' + + return ( +
+
+

بيانات المورد الأساسية

+
+
updateField('companyName', e.target.value)} className={inputClass} placeholder="اسم الشركة أو المورد" />{formErrors.companyName &&

{formErrors.companyName}

}
+
updateField('companyNameAr', e.target.value)} className={inputClass} dir="rtl" />
+
updateField('name', e.target.value)} className={inputClass} placeholder="اسم الشخص المسؤول" />
+
updateField('contactPosition', e.target.value)} className={inputClass} placeholder="Sales Manager, Accountant..." />
+
+
+
+

التصنيف والاعتماد

+
+
updateField('supplierCode', e.target.value)} className={`${inputClass} font-mono`} placeholder="SUP-001" />
+
setForm((prev) => ({ ...prev, supplierCategories }))} />
+ {isEdit &&
} +
+
{[1, 2, 3, 4, 5].map((star) => )}{form.rating > 0 && }
+
+

بيانات التواصل

updateField('email', e.target.value)} className={inputClass} />{formErrors.email &&

{formErrors.email}

}
updateField('phone', e.target.value)} className={inputClass} />
updateField('mobile', e.target.value)} className={inputClass} />
updateField('website', e.target.value)} className={inputClass} />
+

بيانات مالية وقانونية

updateField('taxNumber', e.target.value)} className={`${inputClass} font-mono`} />
updateField('commercialRegister', e.target.value)} className={`${inputClass} font-mono`} />
updateField('paymentTerms', e.target.value)} className={inputClass} placeholder="Net 30, Cash..." />
updateField('bankName', e.target.value)} className={inputClass} />
updateField('bankAccount', e.target.value)} className={`${inputClass} font-mono`} />
+

العنوان والملاحظات

updateField('address', e.target.value)} className={inputClass} />
updateField('city', e.target.value)} className={inputClass} />
updateField('country', e.target.value)} className={inputClass} />
updateField('tags', e.target.value)} className={inputClass} placeholder="tag1, tag2" />