add supplier management module

This commit is contained in:
Aya
2026-05-06 10:56:31 +03:00
parent 8621096a82
commit da4cb36036
22 changed files with 1579 additions and 583 deletions

View File

@@ -29,13 +29,13 @@ 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,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom 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, 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); const result = await contactsService.findAll(filters, page, pageSize);
@@ -242,6 +242,7 @@ class ContactsController {
category: req.query.category as string, category: req.query.category as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true', excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
excludeSuppliers: req.query.excludeSuppliers === 'true',
}; };
const buffer = await contactsService.export(filters); const buffer = await contactsService.export(filters);

View File

@@ -36,7 +36,6 @@ 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;
@@ -44,6 +43,7 @@ interface SearchFilters {
createdFrom?: Date; createdFrom?: Date;
createdTo?: Date; createdTo?: Date;
excludeCompanyEmployees?: boolean; excludeCompanyEmployees?: boolean;
excludeSuppliers?: boolean;
} }
class ContactsService { class ContactsService {
@@ -149,12 +149,6 @@ 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;
} }
@@ -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) { if (filters.createdFrom || filters.createdTo) {
where.createdAt = {}; where.createdAt = {};
if (filters.createdFrom) { if (filters.createdFrom) {
@@ -765,6 +775,7 @@ class ContactsService {
const where: Prisma.ContactWhereInput = { const where: Prisma.ContactWhereInput = {
status: { not: 'DELETED' }, status: { not: 'DELETED' },
}; };
const notConditions: Prisma.ContactWhereInput[] = [];
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
@@ -779,19 +790,39 @@ class ContactsService {
if (filters.source) where.source = filters.source; if (filters.source) where.source = filters.source;
if (filters.rating) where.rating = filters.rating; 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) { if (filters.excludeCompanyEmployees) {
const companyEmployeeCategory = await prisma.contactCategory.findFirst({ const companyEmployeeCategory = await prisma.contactCategory.findFirst({
where: { name: 'Company Employee', isActive: true }, where: { name: 'Company Employee', isActive: true },
}); });
if (companyEmployeeCategory) { if (companyEmployeeCategory) {
where.NOT = { notConditions.push({
categories: { categories: {
some: { id: companyEmployeeCategory.id }, some: { id: companyEmployeeCategory.id },
}, },
}; });
} }
} }
if (notConditions.length > 0) {
where.NOT = notConditions;
}
// Fetch all contacts (no pagination for export) // Fetch all contacts (no pagination for export)
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where, where,

View File

@@ -15,22 +15,11 @@ 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 originalName = decodeOriginalFileName(file.originalname); const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
const ext = path.extname(originalName); cb(null, `${crypto.randomUUID()}-${safeName}`);
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,7 +1,6 @@
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 ==========
@@ -360,33 +359,8 @@ 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 },
@@ -411,18 +385,9 @@ 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');
@@ -445,19 +410,6 @@ 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;
} }
@@ -621,16 +573,6 @@ 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;
} }
@@ -676,18 +618,6 @@ 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;
} }
@@ -914,29 +844,6 @@ 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;
} }
@@ -982,7 +889,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({
@@ -999,29 +906,6 @@ 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;
} }
} }
@@ -1077,19 +961,7 @@ private isSystemAdminUser(user: any) {
userId, userId,
}); });
const approvedLoan = await this.findLoanById(id); return 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) {
@@ -1117,18 +989,6 @@ 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) {
@@ -1204,29 +1064,6 @@ 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;
} }
@@ -1237,18 +1074,7 @@ 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) {
@@ -1258,19 +1084,6 @@ 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

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

View File

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

View File

@@ -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<Buffer> {
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();

View File

@@ -1,7 +1,6 @@
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'
@@ -209,25 +208,11 @@ class TendersService {
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
} }
private getComputedTenderStatus<T extends { status?: string | null; closingDate?: Date | string | null }>(tender: T) { private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(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,
@@ -360,9 +345,7 @@ class TendersService {
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, { issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
]; ];
} }
if (filters.status && filters.status !== 'EXPIRED') { if (filters.status) where.status = filters.status;
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;
@@ -378,15 +361,9 @@ 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: filteredTenders, tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
total: filters.status === 'EXPIRED' ? filteredTenders.length : total, total,
page, page,
pageSize, pageSize,
}; };
@@ -543,15 +520,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({
await notificationsService.notifyMany({ data: {
userIds: [assignedUser.id], userId: assignedUser.id,
type: 'TENDER_DIRECTIVE_ASSIGNED', type: 'TENDER_DIRECTIVE_ASSIGNED',
title: 'تم إسناد توجيه مناقصة جديد', title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`, message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
entityType: 'TENDER', entityType: 'TENDER_DIRECTIVE',
entityId: tender.id, entityId: directive.id,
excludeUserIds: [userId], },
}); });
} }
@@ -701,16 +678,6 @@ 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 },
@@ -719,17 +686,14 @@ 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, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -737,7 +701,6 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
@@ -745,7 +708,6 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
@@ -759,12 +721,8 @@ 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',
@@ -772,7 +730,7 @@ class TendersService {
tenderDirectiveId: directiveId, tenderDirectiveId: directiveId,
tenderId: directive.tenderId, tenderId: directive.tenderId,
fileName, fileName,
originalName, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -780,7 +738,6 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
entityId: directiveId, entityId: directiveId,
@@ -788,9 +745,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 },
@@ -808,10 +765,12 @@ 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

@@ -10,6 +10,7 @@ import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes'; import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes'; import tendersRoutes from '../modules/tenders/tenders.routes';
import notificationsRoutes from '../modules/notifications/notifications.routes'; import notificationsRoutes from '../modules/notifications/notifications.routes';
import suppliersRoutes from '../modules/suppliers/suppliers.routes';
const router = Router(); const router = Router();
@@ -18,6 +19,7 @@ router.use('/admin', adminRoutes);
router.use('/dashboard', dashboardRoutes); router.use('/dashboard', dashboardRoutes);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.use('/contacts', contactsRoutes); router.use('/contacts', contactsRoutes);
router.use('/suppliers', suppliersRoutes);
router.use('/crm', crmRoutes); router.use('/crm', crmRoutes);
router.use('/hr', hrRoutes); router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes); router.use('/inventory', inventoryRoutes);
@@ -35,6 +37,7 @@ router.get('/', (req, res) => {
modules: [ modules: [
'Auth', 'Auth',
'Contact Management', 'Contact Management',
'Supplier Management',
'CRM', 'CRM',
'HR Management', 'HR Management',
'Inventory & Assets', 'Inventory & Assets',

View File

@@ -106,7 +106,8 @@ function ContactDetailContent() {
SCHOOL: 'bg-yellow-100 text-yellow-700', SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700', UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-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' return colors[type] || 'bg-gray-100 text-gray-700'
} }
@@ -124,7 +125,8 @@ function ContactDetailContent() {
SCHOOL: 'مدارس - Schools', SCHOOL: 'مدارس - Schools',
UN: 'UN - United Nations', UN: 'UN - United Nations',
NGO: 'NGO - Non-Governmental Organization', NGO: 'NGO - Non-Governmental Organization',
INSTITUTION: 'مؤسسة - Institution' INSTITUTION: 'مؤسسة - Institution',
SUPPLIER: 'مورّد - Supplier'
} }
return labels[type] || type return labels[type] || type
} }
@@ -370,7 +372,7 @@ function ContactDetailContent() {
{ id: 'address', label: 'Address', icon: MapPin }, { id: 'address', label: 'Address', icon: MapPin },
{ id: 'categories', label: 'Categories & Tags', icon: Tag }, { id: 'categories', label: 'Categories & Tags', icon: Tag },
{ id: 'relationships', label: 'Relationships', icon: Users }, { id: 'relationships', label: 'Relationships', icon: Users },
...((contact.type === 'COMPANY' || contact.type === 'HOLDING') ...((['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type))
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }] ? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
: [] : []
), ),
@@ -646,7 +648,7 @@ function ContactDetailContent() {
)} )}
{/* Hierarchy Tab */} {/* Hierarchy Tab */}
{activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && ( {activeTab === 'hierarchy' && (['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) && (
<div> <div>
<HierarchyTree rootContactId={contactId} /> <HierarchyTree rootContactId={contactId} />
</div> </div>

View File

@@ -29,6 +29,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts' import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
import { categoriesAPI, Category } from '@/lib/api/categories' import { categoriesAPI, Category } from '@/lib/api/categories'
import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories'
import ContactForm from '@/components/contacts/ContactForm' import ContactForm from '@/components/contacts/ContactForm'
import ContactImport from '@/components/contacts/ContactImport' import ContactImport from '@/components/contacts/ContactImport'
@@ -54,8 +55,6 @@ 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')
@@ -80,11 +79,11 @@ function ContactsContent() {
const filters: ContactFilters = { const filters: ContactFilters = {
page: currentPage, page: currentPage,
pageSize, pageSize,
excludeSuppliers: true,
} }
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)
@@ -100,7 +99,7 @@ function ContactsContent() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
useEffect(() => { useEffect(() => {
const debounce = setTimeout(() => { const debounce = setTimeout(() => {
@@ -112,7 +111,7 @@ function ContactsContent() {
useEffect(() => { useEffect(() => {
fetchContacts() fetchContacts()
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization]) }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
const handleCreate = async (data: CreateContactData) => { const handleCreate = async (data: CreateContactData) => {
setSubmitting(true) setSubmitting(true)
@@ -195,7 +194,8 @@ function ContactsContent() {
SCHOOL: 'bg-yellow-100 text-yellow-700', SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700', UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-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' return colors[type] || 'bg-gray-100 text-gray-700'
} }
@@ -217,7 +217,8 @@ function ContactsContent() {
SCHOOL: 'مدارس', SCHOOL: 'مدارس',
UN: 'UN', UN: 'UN',
NGO: 'NGO', NGO: 'NGO',
INSTITUTION: 'مؤسسة' INSTITUTION: 'مؤسسة',
SUPPLIER: 'مورّد'
} }
return labels[type] || type return labels[type] || type
} }
@@ -234,6 +235,7 @@ function ContactsContent() {
'UN', 'UN',
'NGO', 'NGO',
'INSTITUTION', 'INSTITUTION',
'SUPPLIER',
]) ])
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type) const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
@@ -250,21 +252,6 @@ 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">
@@ -325,18 +312,6 @@ 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"
@@ -436,10 +411,8 @@ 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)}
@@ -484,23 +457,7 @@ 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
@@ -525,7 +482,9 @@ function ContactsContent() {
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"
> >
<option value="all">All Categories</option> <option value="all">All Categories</option>
{flattenCategories(categories).map((cat) => ( {flattenCategories(categories)
.filter((cat) => !isSupplierOnlyCategoryName(cat.name, cat.nameAr))
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option> <option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
))} ))}
</select> </select>
@@ -541,7 +500,6 @@ 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"
> >
@@ -577,7 +535,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"
> >
{createDefaultType === 'SUPPLIER' ? 'إضافة مورد' : 'Create New Contact'} Create First Contact
</button> </button>
</div> </div>
) : ( ) : (
@@ -781,8 +739,7 @@ function ContactsContent() {
size="xl" size="xl"
> >
<ContactForm <ContactForm
key={`create-${createDefaultType}`} key="create-contact"
defaultType={createDefaultType}
onSubmit={async (data) => { onSubmit={async (data) => {
await handleCreate(data as CreateContactData) await handleCreate(data as CreateContactData)
}} }}
@@ -870,6 +827,7 @@ function ContactsContent() {
if (selectedStatus !== 'all') filters.status = selectedStatus if (selectedStatus !== 'all') filters.status = selectedStatus
if (selectedCategory !== 'all') filters.category = selectedCategory if (selectedCategory !== 'all') filters.category = selectedCategory
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
filters.excludeSuppliers = true
const blob = await contactsAPI.export(filters) const blob = await contactsAPI.export(filters)
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)

View File

@@ -22,7 +22,8 @@ import {
Settings, Settings,
Bell, Bell,
Shield, Shield,
FileText FileText,
Truck
} from 'lucide-react' } from 'lucide-react'
import { dashboardAPI, notificationsAPI } from '@/lib/api' import { dashboardAPI, notificationsAPI } from '@/lib/api'
import { portalAPI } from '@/lib/api/portal' import { portalAPI } from '@/lib/api/portal'
@@ -254,7 +255,17 @@ function DashboardContent() {
icon: Users, icon: Users,
color: 'bg-blue-500', color: 'bg-blue-500',
href: '/contacts', href: '/contacts',
description: 'إدارة العملاء والموردين وجهات الاتصال', description: 'إدارة العملاء وجهات الاتصال',
permission: 'contacts'
},
{
id: 'suppliers',
name: 'إدارة الموردين',
nameEn: 'Supplier Management',
icon: Truck,
color: 'bg-emerald-500',
href: '/suppliers',
description: 'إدارة الموردين وبيانات التواصل والاعتماد',
permission: 'contacts' permission: 'contacts'
}, },
{ {

View File

@@ -11,6 +11,7 @@ import {
TrendingUp, TrendingUp,
Package, Package,
CheckSquare, CheckSquare,
Truck,
LogIn LogIn
} from 'lucide-react' } from 'lucide-react'
@@ -39,7 +40,12 @@ export default function Home() {
{ {
icon: Users, icon: Users,
title: 'إدارة جهات الاتصال', title: 'إدارة جهات الاتصال',
description: 'نظام شامل لإدارة العملاء والموردين وجهات الاتصال' description: 'نظام شامل لإدارة العملاء وجهات الاتصال'
},
{
icon: Truck,
title: 'إدارة الموردين',
description: 'وحدة مستقلة لإدارة الموردين وبيانات الاعتماد والتواصل'
}, },
{ {
icon: TrendingUp, icon: TrendingUp,

View File

@@ -337,14 +337,15 @@ export default function ManagedExpenseClaimsPage() {
<div className="space-y-1"> <div className="space-y-1">
{claim.attachments.map((attachment) => ( {claim.attachments.map((attachment) => (
<button <a
key={attachment.id} key={attachment.id}
type="button" href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
onClick={() => openAttachment(attachment)} target="_blank"
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline" rel="noreferrer"
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
> >
{attachment.originalName} {attachment.originalName}
</button> </a>
))} ))}
</div> </div>
</div> </div>

View File

@@ -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 <span className="text-gray-400 text-sm">بدون تقييم</span>
return <div className="flex items-center gap-1">{[1, 2, 3, 4, 5].map((star) => <Star key={star} className={`h-5 w-5 ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`} />)}</div>
}
function Field({ label, value, mono = false }: { label: string; value?: any; mono?: boolean }) {
if (!value) return null
return <div><dt className="text-sm font-medium text-gray-500">{label}</dt><dd className={`mt-1 text-sm text-gray-900 ${mono ? 'font-mono' : ''}`}>{value}</dd></div>
}
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 <span className="text-gray-400 text-sm">بدون تصنيف</span>
return (
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<span key={label} className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">
<Tag className="h-3 w-3" />
{label}
</span>
))}
</div>
)
}
function SupplierDetailContent() {
const params = useParams()
const supplierId = params.id as string
const [supplier, setSupplier] = useState<Supplier | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(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 <div className="min-h-screen bg-gray-50 flex items-center justify-center"><LoadingSpinner size="lg" message="Loading supplier details..." /></div>
if (error || !supplier) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><div className="text-center"><XCircle className="h-16 w-16 text-red-500 mx-auto mb-4" /><h2 className="text-2xl font-bold text-gray-900 mb-2">Supplier Not Found</h2><p className="text-gray-600 mb-6">{error || 'This supplier does not exist'}</p><Link href="/suppliers" className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"><ArrowLeft className="h-4 w-4" /> Back to Suppliers</Link></div></div>
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 (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/suppliers" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><ArrowLeft className="h-5 w-5 text-gray-600" /></Link>
<div className="flex items-center gap-3"><div className="bg-emerald-100 p-2 rounded-lg"><Truck className="h-6 w-6 text-emerald-600" /></div><div><div className="flex items-center gap-3"><h1 className="text-2xl font-bold text-gray-900">{supplierName}</h1><span className={`px-3 py-1 rounded-full text-xs font-medium ${supplier.status === 'ACTIVE' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>{supplier.status}</span></div><p className="text-sm text-gray-600 mt-1">Supplier Management {supplier.uniqueContactId}</p></div></div>
</div>
</div>
<nav className="flex items-center gap-2 text-sm text-gray-600 mt-4"><Link href="/dashboard" className="hover:text-emerald-600">Dashboard</Link><span>/</span><Link href="/suppliers" className="hover:text-emerald-600">Suppliers</Link><span>/</span><span className="text-gray-900 font-medium">{supplierName}</span></nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1"><div className="bg-white rounded-xl shadow-sm border p-6"><div className="text-center mb-6"><div className="h-32 w-32 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-white text-4xl font-bold mx-auto mb-4">{supplierName.charAt(0).toUpperCase()}</div><h2 className="text-xl font-bold text-gray-900">{supplierName}</h2>{supplier.companyNameAr && <p className="text-gray-600 mt-1" dir="rtl">{supplier.companyNameAr}</p>}{customFields.supplierCode && <p className="text-sm text-gray-500 mt-2 font-mono">{customFields.supplierCode}</p>}</div><div className="mb-6 pb-6 border-b"><label className="text-sm font-medium text-gray-700 block mb-2">Rating</label>{renderStars(supplier.rating)}</div><div className="space-y-2">{supplier.email && <button onClick={() => copyToClipboard(supplier.email!, 'Email')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Mail className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.email}</span><Copy className={`h-4 w-4 ${copiedField === 'Email' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{(supplier.phone || supplier.mobile) && <button onClick={() => copyToClipboard((supplier.phone || supplier.mobile)!, 'Phone')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Phone className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.phone || supplier.mobile}</span><Copy className={`h-4 w-4 ${copiedField === 'Phone' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{supplier.website && <a href={supplier.website.startsWith('http') ? supplier.website : `https://${supplier.website}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Globe className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.website}</span></a>}</div><div className="mt-6 pt-6 border-t space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Calendar className="h-4 w-4" /><span>Created: {new Date(supplier.createdAt).toLocaleDateString()}</span></div><div className="space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Tag className="h-4 w-4" /><span>Categories</span></div><CategoryBadges labels={categoryLabels} /></div></div></div></div>
<div className="lg:col-span-2 space-y-6">
<InfoCard icon={Building2} title="Supplier Information"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Supplier Name" value={supplierName} /><Field label="Arabic Name" value={supplier.companyNameAr} /><Field label="Contact Person" value={contactPerson} /><Field label="Contact Position" value={customFields.contactPosition} /><Field label="Supplier Code" value={customFields.supplierCode} mono /><div><dt className="text-sm font-medium text-gray-500">Supplier Categories</dt><dd className="mt-2"><CategoryBadges labels={categoryLabels} /></dd></div></dl></InfoCard>
<InfoCard icon={Landmark} title="Legal & Financial"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Tax Number" value={supplier.taxNumber} mono /><Field label="Commercial Register" value={supplier.commercialRegister} mono /><Field label="Payment Terms" value={customFields.paymentTerms} /><Field label="Bank Name" value={customFields.bankName} /><Field label="Bank Account / IBAN" value={customFields.bankAccount} mono /></dl>{!supplier.taxNumber && !supplier.commercialRegister && !customFields.paymentTerms && !customFields.bankName && !customFields.bankAccount && <div className="text-center py-6 text-gray-500"><CircleDollarSign className="h-10 w-10 mx-auto mb-2 text-gray-300" /><p>No financial information available</p></div>}</InfoCard>
<InfoCard icon={MapPin} title="Address & Notes"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Address" value={supplier.address} /><Field label="City" value={supplier.city} /><Field label="Country" value={supplier.country} /><Field label="Postal Code" value={supplier.postalCode} /></dl>{customFields.notes && <div className="mt-6 pt-6 border-t"><div className="flex items-center gap-2 mb-2"><FileText className="h-4 w-4 text-gray-500" /><h4 className="font-medium text-gray-900">Notes</h4></div><p className="text-sm text-gray-700 whitespace-pre-wrap">{customFields.notes}</p></div>}</InfoCard>
</div>
</div>
</main>
</div>
)
}
function InfoCard({ icon: Icon, title, children }: { icon: any; title: string; children: ReactNode }) {
return <div className="bg-white rounded-xl shadow-sm border p-6"><div className="flex items-center gap-2 mb-4"><Icon className="h-5 w-5 text-emerald-600" /><h3 className="text-lg font-semibold text-gray-900">{title}</h3></div>{children}</div>
}
export default function SupplierDetailPage() {
return <ProtectedRoute><SupplierDetailContent /></ProtectedRoute>
}

File diff suppressed because one or more lines are too long

View File

@@ -3,15 +3,17 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react' import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
import { categoriesAPI, Category } from '@/lib/api/categories' import { categoriesAPI, Category } from '@/lib/api/categories'
import { filterContactCategoryTree } from '@/lib/supplierCategories'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
interface CategorySelectorProps { interface CategorySelectorProps {
selectedIds: string[] selectedIds: string[]
onChange: (selectedIds: string[]) => void onChange: (selectedIds: string[]) => void
multiSelect?: boolean multiSelect?: boolean
categoryFilter?: (category: Category) => boolean
} }
export default function CategorySelector({ selectedIds, onChange, multiSelect = true }: CategorySelectorProps) { export default function CategorySelector({ selectedIds, onChange, multiSelect = true, categoryFilter }: CategorySelectorProps) {
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
@@ -25,11 +27,28 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
fetchCategories() fetchCategories()
}, []) }, [])
const filterCategoryTree = (items: Category[]): Category[] => {
if (!categoryFilter) return items
return items
.map((category) => {
const children = category.children ? filterCategoryTree(category.children) : []
const shouldShow = categoryFilter(category)
if (!shouldShow && children.length === 0) return null
return { ...category, children } as Category
})
.filter(Boolean) as Category[]
}
const visibleCategories = filterCategoryTree(categories)
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
try { try {
const data = await categoriesAPI.getTree() const data = await categoriesAPI.getTree()
setCategories(data) setCategories(filterContactCategoryTree(data))
} catch (error) { } catch (error) {
toast.error('Failed to load categories') toast.error('Failed to load categories')
} finally { } finally {
@@ -102,6 +121,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Expand/Collapse */} {/* Expand/Collapse */}
{hasChildren ? ( {hasChildren ? (
<button <button
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
toggleExpand(category.id) toggleExpand(category.id)
@@ -127,6 +147,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Category Name */} {/* Category Name */}
<button <button
type="button"
onClick={() => toggleSelect(category.id)} onClick={() => toggleSelect(category.id)}
className="flex-1 text-left flex items-center gap-2" className="flex-1 text-left flex items-center gap-2"
> >
@@ -179,7 +200,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
} }
return selectedIds return selectedIds
.map(id => findCategory(categories, id)) .map(id => findCategory(visibleCategories, id))
.filter(cat => cat !== null) as Category[] .filter(cat => cat !== null) as Category[]
} }
@@ -203,6 +224,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
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" 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"
/> />
<button <button
type="button"
onClick={() => setShowAddModal(true)} onClick={() => setShowAddModal(true)}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
title="Add Category" title="Add Category"
@@ -221,6 +243,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
> >
{category.name} {category.name}
<button <button
type="button"
onClick={() => removeSelected(category.id)} onClick={() => removeSelected(category.id)}
className="hover:text-blue-900" className="hover:text-blue-900"
> >
@@ -233,11 +256,12 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Category Tree */} {/* Category Tree */}
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white"> <div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
{categories.length === 0 ? ( {visibleCategories.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" /> <Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No categories found</p> <p>No categories found</p>
<button <button
type="button"
onClick={() => setShowAddModal(true)} onClick={() => setShowAddModal(true)}
className="mt-2 text-blue-600 hover:text-blue-700 text-sm" className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
> >
@@ -245,7 +269,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
</button> </button>
</div> </div>
) : ( ) : (
categories.map(category => renderCategory(category)) visibleCategories.map(category => renderCategory(category))
)} )}
</div> </div>
@@ -296,7 +320,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
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"
> >
<option value="">None (Root Category)</option> <option value="">None (Root Category)</option>
{categories.map(cat => ( {visibleCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option> <option key={cat.id} value={cat.id}>{cat.name}</option>
))} ))}
</select> </select>
@@ -305,6 +329,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
<div className="flex items-center justify-end gap-3 mt-6"> <div className="flex items-center justify-end gap-3 mt-6">
<button <button
type="button"
onClick={() => { onClick={() => {
setShowAddModal(false) setShowAddModal(false)
setNewCategoryName('') setNewCategoryName('')
@@ -316,6 +341,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
Cancel Cancel
</button> </button>
<button <button
type="button"
onClick={handleAddCategory} onClick={handleAddCategory}
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"
> >

View File

@@ -13,7 +13,6 @@ 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 => ({
@@ -40,12 +39,10 @@ const buildInitialFormData = (contact?: Contact): CreateContactData => ({
customFields: contact?.customFields customFields: contact?.customFields
}) })
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false, defaultType = 'INDIVIDUAL' }: ContactFormProps) { const isEdit = !!contact export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact
const [formData, setFormData] = useState<CreateContactData>({ const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
...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>>({})
@@ -53,13 +50,11 @@ const [formData, setFormData] = useState<CreateContactData>({
const [employees, setEmployees] = useState<Employee[]>([]) const [employees, setEmployees] = useState<Employee[]>([])
useEffect(() => { useEffect(() => {
setFormData({ setFormData(buildInitialFormData(contact))
...buildInitialFormData(contact),
type: contact?.type || defaultType,
})
setRating(contact?.rating || 0) setRating(contact?.rating || 0)
setNewTag('')
setFormErrors({}) setFormErrors({})
}, [contact, defaultType]) }, [contact])
useEffect(() => { useEffect(() => {
categoriesAPI.getTree().then(setCategories).catch(() => {}) categoriesAPI.getTree().then(setCategories).catch(() => {})
@@ -96,24 +91,11 @@ const [formData, setFormData] = useState<CreateContactData>({
'UN', 'UN',
'NGO', 'NGO',
'INSTITUTION', 'INSTITUTION',
'SUPPLIER',
]) ])
const isSupplier = formData.type === 'SUPPLIER'
const isOrganizationType = organizationTypes.has(formData.type) const isOrganizationType = organizationTypes.has(formData.type)
const showCompanyFields = isOrganizationType && !isSupplier const showCompanyFields = isOrganizationType
const supplierSpecializations = [
'كاميرات',
'شبكات',
'أجهزة كومبيوتر',
'projectors',
'مقاسم هاتفية',
' Mobile - Tablet',
'firewall',
'طاقة بديلة',
'حديد',
' باركود - POS',
'أجهزة منزلية',
'تكييف وتبريد',
]
const validateForm = (): boolean => { const validateForm = (): boolean => {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
@@ -122,10 +104,6 @@ const [formData, setFormData] = useState<CreateContactData>({
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'
} }
@@ -246,7 +224,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> <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>
@@ -275,73 +253,18 @@ 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">
{isSupplier ? 'اسم المورد' : isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span> {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={isSupplier ? 'أدخل اسم المورد' : isOrganizationType ? 'Enter contact person name' : 'Enter contact name'} placeholder={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
@@ -374,7 +297,6 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
@@ -493,24 +415,7 @@ 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"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
{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">
@@ -527,7 +432,9 @@ 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">City</label> <label className="block text-sm font-medium text-gray-700 mb-1">
City
</label>
<input <input
type="text" type="text"
value={formData.city || ''} value={formData.city || ''}
@@ -538,7 +445,9 @@ 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">Country</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Country
</label>
<input <input
type="text" type="text"
value={formData.country || ''} value={formData.country || ''}
@@ -549,7 +458,9 @@ 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">Postal Code</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code
</label>
<input <input
type="text" type="text"
value={formData.postalCode || ''} value={formData.postalCode || ''}
@@ -560,9 +471,8 @@ 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
@@ -571,7 +481,6 @@ 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">
@@ -594,7 +503,6 @@ 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">
@@ -637,7 +545,6 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)} )}
</div> </div>
</div> </div>
)}
<DuplicateAlert <DuplicateAlert
email={formData.email} email={formData.email}

View File

@@ -0,0 +1,168 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Check, Folder, Plus, X } from 'lucide-react'
import { toast } from 'react-hot-toast'
import { DEFAULT_SUPPLIER_CATEGORIES, normalizeSupplierCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
interface SupplierCategorySelectorProps {
selectedCategories: string[]
onChange: (categories: string[]) => void
availableCategories?: string[]
}
const STORAGE_KEY = 'zerp_supplier_custom_categories'
function readStoredCategories(): string[] {
if (typeof window === 'undefined') return []
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
function writeStoredCategories(categories: string[]) {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(categories))
}
export default function SupplierCategorySelector({ selectedCategories, onChange, availableCategories = [] }: SupplierCategorySelectorProps) {
const [searchTerm, setSearchTerm] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [storedCategories, setStoredCategories] = useState<string[]>([])
useEffect(() => {
setStoredCategories(readStoredCategories())
}, [])
const options = useMemo(
() => uniqueSupplierCategories([
...DEFAULT_SUPPLIER_CATEGORIES,
...availableCategories,
...storedCategories,
...selectedCategories,
]),
[availableCategories, selectedCategories, storedCategories],
)
const filteredOptions = options.filter((category) =>
normalizeSupplierCategoryName(category).includes(normalizeSupplierCategoryName(searchTerm)),
)
const isSelected = (category: string) =>
selectedCategories.some((selected) => normalizeSupplierCategoryName(selected) === normalizeSupplierCategoryName(category))
const toggleCategory = (category: string) => {
if (isSelected(category)) {
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
return
}
onChange(uniqueSupplierCategories([...selectedCategories, category]))
}
const removeCategory = (category: string) => {
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
}
const addCategory = () => {
const label = newCategoryName.trim()
if (!label) {
toast.error('اسم التصنيف مطلوب')
return
}
const nextStored = uniqueSupplierCategories([...storedCategories, label])
setStoredCategories(nextStored)
writeStoredCategories(nextStored)
onChange(uniqueSupplierCategories([...selectedCategories, label]))
setNewCategoryName('')
setShowAddModal(false)
toast.success('تمت إضافة التصنيف')
}
return (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search categories..."
className="flex-1 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"
/>
<button type="button" onClick={() => setShowAddModal(true)} className="px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors" title="Add Category">
<Plus className="h-5 w-5" />
</button>
</div>
{selectedCategories.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg">
{selectedCategories.map((category) => (
<span key={category} className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm">
{category}
<button type="button" onClick={() => removeCategory(category)} className="hover:text-emerald-900"><X className="h-3 w-3" /></button>
</span>
))}
</div>
)}
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
{filteredOptions.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>لا يوجد تصنيفات مطابقة</p>
<button type="button" onClick={() => setShowAddModal(true)} className="mt-2 text-emerald-600 hover:text-emerald-700 text-sm">إضافة تصنيف جديد</button>
</div>
) : (
filteredOptions.map((category) => {
const selected = isSelected(category)
return (
<div key={category} className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected ? 'bg-emerald-50 border border-emerald-200' : 'hover:bg-gray-50'}`} onClick={() => toggleCategory(category)}>
<div className="w-6" />
<Folder className="h-4 w-4 text-gray-600" />
<span className="flex-1 text-sm font-medium text-gray-900 text-right">{category}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); toggleCategory(category) }}
className={`w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors ${selected ? 'bg-emerald-600 border-emerald-600' : 'border-gray-300 bg-white hover:border-emerald-400'}`}
>
{selected && <Check className="h-3 w-3 text-white" />}
</button>
</div>
)
})
)}
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowAddModal(false)} />
<div className="flex min-h-screen items-center justify-center p-4">
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
<h3 className="text-lg font-bold text-gray-900 mb-4">إضافة تصنيف مورد</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">اسم التصنيف <span className="text-red-500">*</span></label>
<input
type="text"
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCategory() } }}
className="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"
placeholder="مثال: أجهزة طباعة"
autoFocus
/>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button type="button" onClick={() => { setShowAddModal(false); setNewCategoryName('') }} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">إلغاء</button>
<button type="button" onClick={addCategory} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">إضافة</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -65,13 +65,13 @@ 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
rating?: number rating?: number
page?: number page?: number
pageSize?: number pageSize?: number
excludeSuppliers?: boolean
} }
export interface ContactsResponse { export interface ContactsResponse {
@@ -88,13 +88,13 @@ 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)
if (filters.rating) params.append('rating', filters.rating.toString()) if (filters.rating) params.append('rating', filters.rating.toString())
if (filters.page) params.append('page', filters.page.toString()) if (filters.page) params.append('page', filters.page.toString())
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString()) if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
const response = await api.get(`/contacts?${params.toString()}`) const response = await api.get(`/contacts?${params.toString()}`)
const { data, pagination } = response.data const { data, pagination } = response.data
@@ -156,6 +156,7 @@ export const contactsAPI = {
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.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true') if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
const response = await api.get(`/contacts/export?${params.toString()}`, { const response = await api.get(`/contacts/export?${params.toString()}`, {
responseType: 'blob' responseType: 'blob'

View File

@@ -0,0 +1,123 @@
import { api } from '../api'
import { Contact } from './contacts'
export interface Supplier extends Contact {
customFields?: {
supplierCode?: string
supplierCategory?: string
supplierCategories?: string[]
paymentTerms?: string
bankName?: string
bankAccount?: string
contactPosition?: string
notes?: string
[key: string]: any
}
}
export interface SupplierFilters {
search?: string
status?: string
rating?: number
category?: string
page?: number
pageSize?: number
}
export interface SuppliersResponse {
suppliers: Supplier[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface SupplierStats {
total: number
active: number
inactive: number
blocked: number
}
export interface CreateSupplierData {
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
customFields?: Supplier['customFields']
}
export interface UpdateSupplierData extends Partial<CreateSupplierData> {
status?: string
}
const buildParams = (filters: SupplierFilters = {}) => {
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.status) params.append('status', filters.status)
if (filters.rating) params.append('rating', filters.rating.toString())
if (filters.category) params.append('category', filters.category)
if (filters.page) params.append('page', filters.page.toString())
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
return params
}
export const suppliersAPI = {
getAll: async (filters: SupplierFilters = {}): Promise<SuppliersResponse> => {
const params = buildParams(filters)
const response = await api.get(`/suppliers?${params.toString()}`)
const { data, pagination } = response.data
return {
suppliers: data || [],
total: pagination?.total || 0,
page: pagination?.page || 1,
pageSize: pagination?.pageSize || 20,
totalPages: pagination?.totalPages || 0,
}
},
getStats: async (): Promise<SupplierStats> => {
const response = await api.get('/suppliers/stats')
return response.data.data
},
getById: async (id: string): Promise<Supplier> => {
const response = await api.get(`/suppliers/${id}`)
return response.data.data
},
create: async (data: CreateSupplierData): Promise<Supplier> => {
const response = await api.post('/suppliers', data)
return response.data.data
},
update: async (id: string, data: UpdateSupplierData): Promise<Supplier> => {
const response = await api.put(`/suppliers/${id}`, data)
return response.data.data
},
archive: async (id: string, reason?: string): Promise<Supplier> => {
const response = await api.post(`/suppliers/${id}/archive`, { reason })
return response.data.data
},
export: async (filters: SupplierFilters = {}): Promise<Blob> => {
const params = buildParams(filters)
const response = await api.get(`/suppliers/export?${params.toString()}`, { responseType: 'blob' })
return response.data
}
}

View File

@@ -0,0 +1,66 @@
import type { Category } from './api/categories'
export const DEFAULT_SUPPLIER_CATEGORIES = [
'كاميرات',
'شبكات',
'أجهزة كومبيوتر',
'projectors',
'مقاسم هاتفية',
'Mobile - Tablet',
'firewall',
'طاقة بديلة',
'حديد',
'باركود - POS',
'أجهزة منزلية',
'تكييف وتبريد',
]
const SUPPLIER_SYSTEM_NAMES = ['Supplier', 'Suppliers']
const SUPPLIER_SYSTEM_AR_NAMES = ['مورد', 'مورّد', 'موردين']
export function normalizeSupplierCategoryName(value?: string | null) {
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase()
}
export function isSupplierSystemCategoryName(name?: string | null, nameAr?: string | null) {
const normalizedName = normalizeSupplierCategoryName(name)
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
return (
SUPPLIER_SYSTEM_NAMES.map(normalizeSupplierCategoryName).includes(normalizedName) ||
SUPPLIER_SYSTEM_AR_NAMES.some((word) => normalizedNameAr.includes(normalizeSupplierCategoryName(word)))
)
}
export function isSupplierBusinessCategoryName(name?: string | null, nameAr?: string | null) {
const normalizedName = normalizeSupplierCategoryName(name)
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
return DEFAULT_SUPPLIER_CATEGORIES.some((category) => {
const normalizedCategory = normalizeSupplierCategoryName(category)
return normalizedName === normalizedCategory || normalizedNameAr === normalizedCategory
})
}
export function isSupplierOnlyCategoryName(name?: string | null, nameAr?: string | null) {
return isSupplierSystemCategoryName(name, nameAr) || isSupplierBusinessCategoryName(name, nameAr)
}
export function filterContactCategoryTree(categories: Category[]): Category[] {
return categories
.filter((category) => !isSupplierOnlyCategoryName(category.name, category.nameAr))
.map((category) => ({
...category,
children: category.children ? filterContactCategoryTree(category.children) : undefined,
}))
}
export function uniqueSupplierCategories(values: Array<string | undefined | null>) {
const map = new Map<string, string>()
values.forEach((value) => {
const label = String(value || '').trim()
if (!label) return
const key = normalizeSupplierCategoryName(label)
if (!map.has(key)) map.set(key, label)
})
return Array.from(map.values())
}