add supplier management module
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ==========
|
||||||
@@ -353,40 +352,15 @@ class HRService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`;
|
|
||||||
|
|
||||||
await notificationsService.notifyApprovalRecipients({
|
|
||||||
resource: 'leave_requests',
|
|
||||||
fallbackEmployeeId: leave.employeeId,
|
|
||||||
fallbackToManager: true,
|
|
||||||
type: 'LEAVE_REQUEST_SUBMITTED',
|
|
||||||
title: 'طلب إجازة جديد بانتظار الموافقة',
|
|
||||||
message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`,
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: leave.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: leave.employeeId,
|
|
||||||
type: 'LEAVE_REQUEST_CREATED',
|
|
||||||
title: 'تم إرسال طلب الإجازة',
|
|
||||||
message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: leave.id,
|
|
||||||
excludeUserIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
return leave;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -404,25 +378,16 @@ class HRService {
|
|||||||
const year = new Date(leave.startDate).getFullYear();
|
const year = new Date(leave.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
action: 'APPROVE',
|
action: 'APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: leave.employeeId,
|
|
||||||
type: 'LEAVE_REQUEST_APPROVED',
|
|
||||||
title: 'تمت الموافقة على طلب الإجازة',
|
|
||||||
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: leave.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return leave;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
const leave = await prisma.leave.findUnique({ where: { id } });
|
const leave = await prisma.leave.findUnique({ where: { id } });
|
||||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
@@ -438,26 +403,13 @@ class HRService {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
userId,
|
userId,
|
||||||
reason: rejectedReason,
|
reason: rejectedReason,
|
||||||
});
|
});
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: updated.employeeId,
|
|
||||||
type: 'LEAVE_REQUEST_REJECTED',
|
|
||||||
title: 'تم رفض طلب الإجازة',
|
|
||||||
message: rejectedReason
|
|
||||||
? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}`
|
|
||||||
: 'تم رفض طلب الإجازة الخاص بك.',
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: updated.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,23 +566,13 @@ async findManagedLeaves(status?: string) {
|
|||||||
const year = new Date(updated.startDate).getFullYear();
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_APPROVE',
|
action: 'MANAGER_APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: updated.employeeId,
|
|
||||||
type: 'LEAVE_REQUEST_APPROVED',
|
|
||||||
title: 'تمت الموافقة على طلب الإجازة',
|
|
||||||
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: updated.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +610,7 @@ async findManagedLeaves(status?: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
action: 'MANAGER_REJECT',
|
action: 'MANAGER_REJECT',
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,30 +843,7 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||||
|
|
||||||
const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`;
|
|
||||||
|
|
||||||
await notificationsService.notifyApprovalRecipients({
|
|
||||||
resource: 'loan_requests',
|
|
||||||
type: 'LOAN_REQUEST_SUBMITTED',
|
|
||||||
title: 'طلب قرض جديد بانتظار الموافقة',
|
|
||||||
message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`,
|
|
||||||
entityType: 'LOAN',
|
|
||||||
entityId: loan.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: loan.employeeId,
|
|
||||||
type: 'LOAN_REQUEST_CREATED',
|
|
||||||
title: 'تم إرسال طلب القرض',
|
|
||||||
message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`,
|
|
||||||
entityType: 'LOAN',
|
|
||||||
entityId: loan.id,
|
|
||||||
excludeUserIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
return loan;
|
return loan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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({
|
||||||
@@ -992,36 +899,13 @@ private isSystemAdminUser(user: any) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fullLoan = await this.findLoanById(id);
|
|
||||||
const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`;
|
|
||||||
|
|
||||||
await notificationsService.notifyApprovalRecipients({
|
|
||||||
resource: 'loan_requests',
|
|
||||||
type: 'LOAN_REQUEST_PENDING_ADMIN',
|
|
||||||
title: 'طلب قرض محال إلى مدير النظام',
|
|
||||||
message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`,
|
|
||||||
entityType: 'LOAN',
|
|
||||||
entityId: id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: fullLoan.employee.id,
|
|
||||||
type: 'LOAN_REQUEST_ESCALATED',
|
|
||||||
title: 'تمت إحالة طلب القرض للاعتماد النهائي',
|
|
||||||
message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`,
|
|
||||||
entityType: 'LOAN',
|
|
||||||
entityId: id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedLoan;
|
return updatedLoan;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,26 +954,14 @@ private isSystemAdminUser(user: any) {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
||||||
@@ -1109,7 +981,7 @@ private isSystemAdminUser(user: any) {
|
|||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LOAN',
|
entityType: 'LOAN',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'REJECT',
|
action: 'REJECT',
|
||||||
@@ -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) {
|
||||||
@@ -1203,30 +1063,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,19 +1073,8 @@ private isSystemAdminUser(user: any) {
|
|||||||
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
||||||
include: { employee: true },
|
include: { employee: true },
|
||||||
});
|
});
|
||||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
||||||
|
return req;
|
||||||
await notificationsService.notifyEmployeeUser({
|
|
||||||
employeeId: req.employeeId,
|
|
||||||
type: 'PURCHASE_REQUEST_APPROVED',
|
|
||||||
title: 'تمت الموافقة على طلب الشراء',
|
|
||||||
message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
|
|
||||||
entityType: 'PURCHASE_REQUEST',
|
|
||||||
entityId: req.id,
|
|
||||||
excludeUserIds: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return req;;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
backend/src/modules/suppliers/suppliers.controller.ts
Normal file
89
backend/src/modules/suppliers/suppliers.controller.ts
Normal 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();
|
||||||
66
backend/src/modules/suppliers/suppliers.routes.ts
Normal file
66
backend/src/modules/suppliers/suppliers.routes.ts
Normal 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;
|
||||||
314
backend/src/modules/suppliers/suppliers.service.ts
Normal file
314
backend/src/modules/suppliers/suppliers.service.ts
Normal 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();
|
||||||
@@ -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));
|
return {
|
||||||
const filteredTenders =
|
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
|
||||||
filters.status === 'EXPIRED'
|
total,
|
||||||
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
|
|
||||||
: mappedTenders;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tenders: filteredTenders,
|
|
||||||
total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
|
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
@@ -540,20 +517,20 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const assignedUser = directive.assignedToEmployee?.user;
|
const assignedUser = directive.assignedToEmployee?.user;
|
||||||
if (assignedUser?.id) {
|
if (assignedUser?.id) {
|
||||||
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
const typeLabel = this.getDirectiveTypeLabel(data.type);
|
||||||
|
await prisma.notification.create({
|
||||||
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],
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
@@ -701,17 +678,7 @@ class TendersService {
|
|||||||
return deal;
|
return deal;
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeUploadedFileName(fileName: string) {
|
async uploadTenderAttachment(
|
||||||
if (!fileName) return 'file';
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Buffer.from(fileName, 'latin1').toString('utf8');
|
|
||||||
} catch {
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadTenderAttachment(
|
|
||||||
tenderId: string,
|
tenderId: string,
|
||||||
file: { path: string; originalname: string; mimetype: string; size: number },
|
file: { path: string; originalname: string; mimetype: string; size: number },
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,20 +312,8 @@ function ContactsContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetForm()
|
resetForm()
|
||||||
setCreateDefaultType('SUPPLIER')
|
|
||||||
setShowCreateModal(true)
|
setShowCreateModal(true)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
إضافة موردين
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
resetForm()
|
|
||||||
setCreateDefaultType('INDIVIDUAL')
|
|
||||||
setShowCreateModal(true)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -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,9 +482,11 @@ 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)
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
.filter((cat) => !isSupplierOnlyCategoryName(cat.name, cat.nameAr))
|
||||||
))}
|
.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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,8 +535,8 @@ function ContactsContent() {
|
|||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{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)
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
139
frontend/src/app/suppliers/[id]/page.tsx
Normal file
139
frontend/src/app/suppliers/[id]/page.tsx
Normal 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>
|
||||||
|
}
|
||||||
323
frontend/src/app/suppliers/page.tsx
Normal file
323
frontend/src/app/suppliers/page.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
@@ -149,7 +127,7 @@ const [formData, setFormData] = useState<CreateContactData>({
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
||||||
const requiredFields = ['type', 'name', 'source', 'country']
|
const requiredFields = ['type', 'name', 'source', 'country']
|
||||||
|
|
||||||
// keep required fields as-is
|
// keep required fields as-is
|
||||||
@@ -181,8 +159,8 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cleanData.parentId) {
|
if (!cleanData.parentId) {
|
||||||
delete cleanData.parentId
|
delete cleanData.parentId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||||
delete cleanData.categories
|
delete cleanData.categories
|
||||||
@@ -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,106 +253,50 @@ 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>
|
||||||
<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
|
||||||
الاختصاص <span className="text-red-500">*</span>
|
</label>
|
||||||
</label>
|
<div className="flex items-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<button
|
||||||
{supplierSpecializations.map((item) => {
|
key={star}
|
||||||
const checked = formData.tags?.includes(item) || false
|
type="button"
|
||||||
return (
|
onClick={() => setRating(star)}
|
||||||
<button
|
className="focus:outline-none transition-colors"
|
||||||
key={item}
|
>
|
||||||
type="button"
|
<Star
|
||||||
onClick={() => {
|
className={`h-8 w-8 ${
|
||||||
setFormData({
|
star <= rating
|
||||||
...formData,
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
tags: checked
|
: 'text-gray-300 hover:text-yellow-200'
|
||||||
? (formData.tags || []).filter((tag) => tag !== item)
|
}`}
|
||||||
: [...(formData.tags || []), item],
|
/>
|
||||||
})
|
</button>
|
||||||
}}
|
))}
|
||||||
className={`px-3 py-1 rounded-full border text-sm transition-colors ${
|
{rating > 0 && (
|
||||||
checked
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTag}
|
|
||||||
onChange={(e) => setNewTag(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
||||||
placeholder="أضف اختصاص آخر"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addTag}
|
onClick={() => setRating(0)}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{formErrors.tags && <p className="text-red-500 text-xs mt-1">{formErrors.tags}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{!isSupplier && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Rating
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<button
|
|
||||||
key={star}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRating(star)}
|
|
||||||
className="focus:outline-none transition-colors"
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={`h-8 w-8 ${
|
|
||||||
star <= rating
|
|
||||||
? 'fill-yellow-400 text-yellow-400'
|
|
||||||
: 'text-gray-300 hover:text-yellow-200'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{rating > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRating(0)}
|
|
||||||
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -493,76 +415,64 @@ 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'}
|
<div className="space-y-4">
|
||||||
</h3>
|
|
||||||
|
|
||||||
{isSupplier ? (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
العنوان
|
Street Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address || ''}
|
value={formData.address || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="العنوان"
|
placeholder="Street address"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street Address
|
City
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address || ''}
|
value={formData.city || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="Street address"
|
placeholder="City"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">City</label>
|
Country
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
value={formData.city || ''}
|
type="text"
|
||||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
value={formData.country || ''}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||||||
placeholder="City"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
/>
|
placeholder="Country"
|
||||||
</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">
|
||||||
<input
|
Postal Code
|
||||||
type="text"
|
</label>
|
||||||
value={formData.country || ''}
|
<input
|
||||||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
type="text"
|
||||||
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"
|
value={formData.postalCode || ''}
|
||||||
placeholder="Country"
|
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
||||||
/>
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
</div>
|
placeholder="Postal code"
|
||||||
|
/>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.postalCode || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
||||||
placeholder="Postal code"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isSupplier && (
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
@@ -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,50 +503,48 @@ 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">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={newTag}
|
||||||
value={newTag}
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
onChange={(e) => setNewTag(e.target.value)}
|
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||||
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"
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
placeholder="Add a tag (press Enter)"
|
||||||
placeholder="Add a tag (press Enter)"
|
/>
|
||||||
/>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={addTag}
|
||||||
onClick={addTag}
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
>
|
||||||
>
|
<Plus className="h-5 w-5" />
|
||||||
<Plus className="h-5 w-5" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.tags && formData.tags.length > 0 && (
|
{formData.tags && formData.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formData.tags.map((tag, index) => (
|
{formData.tags.map((tag, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeTag(tag)}
|
onClick={() => removeTag(tag)}
|
||||||
className="hover:text-red-600 transition-colors"
|
className="hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<DuplicateAlert
|
<DuplicateAlert
|
||||||
email={formData.email}
|
email={formData.email}
|
||||||
|
|||||||
168
frontend/src/components/suppliers/SupplierCategorySelector.tsx
Normal file
168
frontend/src/components/suppliers/SupplierCategorySelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
123
frontend/src/lib/api/suppliers.ts
Normal file
123
frontend/src/lib/api/suppliers.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/src/lib/supplierCategories.ts
Normal file
66
frontend/src/lib/supplierCategories.ts
Normal 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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user