Compare commits

..

16 Commits

Author SHA1 Message Date
Aya
96386887fb edit for portal & tender 2026-06-03 13:01:51 +03:00
Aya
61ca570e7a edits for suppliers & projects 2026-05-20 11:41:38 +03:00
Aya
12c4ca8334 edits for trenders attachments & claims 2026-05-19 11:41:44 +03:00
Aya
7732a40726 upload file 2026-05-07 16:51:45 +03:00
Aya
31c59a3c9f edit status of tenders 2026-05-07 16:16:31 +03:00
Aya
e01e351713 updates 2026-05-07 15:21:10 +03:00
Aya
9e5dd47a2f add permission to suppliers 2026-05-06 15:17:58 +03:00
Aya
da4cb36036 add supplier management module 2026-05-06 10:56:31 +03:00
Aya
8621096a82 add suppliers 2026-05-03 15:25:50 +03:00
Aya
287401f1da add notification route 2026-05-03 11:53:05 +03:00
Aya
345ba195f8 update expense-claims 2026-05-03 10:30:03 +03:00
Aya
11d14c01d2 edit tender module 2026-04-26 12:02:45 +03:00
Aya
0a9e1bbd4d addition expense claims 2026-04-22 11:36:47 +03:00
Aya
e262d8c09c notification process 2026-04-19 15:16:45 +03:00
Aya
417a5ac661 add edit & delete button to tender & update contacts dashbaord 2026-04-14 14:50:37 +03:00
Aya
18699e6926 add edit & delete button to tender & update contacts dashbaord 2026-04-14 14:47:10 +03:00
61 changed files with 8806 additions and 715 deletions

View File

@@ -56,8 +56,8 @@ RUN npm ci --only=production && \
# Copy built application # Copy built application
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Change ownership of all files to the nodejs user # Ensure uploads directory exists and is owned by app user
RUN chown -R expressjs:nodejs /app RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app
# Switch to non-root user # Switch to non-root user
USER expressjs USER expressjs

View File

@@ -0,0 +1,22 @@
-- Add 'mark-as-paid' action to department_expense_claims permission for positions
-- that already have the 'approve' action on it.
-- This is intended for rolling out the new "mark expense claim as paid" feature
-- to managers/accountants who currently approve claims, without granting it to everyone.
--
-- Safe to run multiple times: only updates rows that don't already have the action.
--
-- Run on server:
-- docker-compose exec -T postgres psql -U postgres -d mind14_crm -f - < backend/prisma/add-expense-mark-paid-permission.sql
-- Or from backend:
-- npx prisma db execute --file prisma/add-expense-mark-paid-permission.sql
UPDATE position_permissions
SET
actions = actions || '["mark-as-paid"]'::jsonb,
"updatedAt" = NOW()
WHERE module = 'department_expense_claims'
AND resource = '*'
AND NOT (actions @> '["mark-as-paid"]'::jsonb)
AND NOT (actions @> '["*"]'::jsonb)
AND NOT (actions @> '["all"]'::jsonb)
AND actions @> '["approve"]'::jsonb;

View File

@@ -0,0 +1,26 @@
CREATE TABLE "expense_claims" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"claimNumber" TEXT NOT NULL,
"expenseDate" DATE,
"amount" DECIMAL(12,2),
"description" TEXT,
"projectOrTender" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expense_claims_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "expense_claims_claimNumber_key" ON "expense_claims"("claimNumber");
CREATE INDEX "expense_claims_employeeId_idx" ON "expense_claims"("employeeId");
CREATE INDEX "expense_claims_status_idx" ON "expense_claims"("status");
ALTER TABLE "expense_claims"
ADD CONSTRAINT "expense_claims_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "employees"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
ALTER TABLE "expense_claims"
ADD COLUMN "items" JSONB;
ALTER TABLE "expense_claims"
ADD COLUMN "totalAmount" DECIMAL(12,2);
UPDATE "expense_claims"
SET "totalAmount" = "amount"
WHERE "totalAmount" IS NULL AND "amount" IS NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Add issue_number column for tenders + supporting index
ALTER TABLE "tenders" ADD COLUMN "issueNumber" TEXT;
CREATE INDEX "tenders_issueNumber_idx" ON "tenders"("issueNumber");

View File

@@ -200,6 +200,7 @@ model Employee {
commissions Commission[] commissions Commission[]
loans Loan[] loans Loan[]
purchaseRequests PurchaseRequest[] purchaseRequests PurchaseRequest[]
expenseClaims ExpenseClaim[]
leaveEntitlements LeaveEntitlement[] leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[] employeeContracts EmployeeContract[]
tenderDirectivesAssigned TenderDirective[] tenderDirectivesAssigned TenderDirective[]
@@ -301,8 +302,8 @@ model Leave {
employeeId String employeeId String
employee Employee @relation(fields: [employeeId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id])
leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc. leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc.
startDate DateTime @db.Date startDate DateTime @db.Timestamp(3)
endDate DateTime @db.Date endDate DateTime @db.Timestamp(3)
days Int days Int
reason String? reason String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED status String @default("PENDING") // PENDING, APPROVED, REJECTED
@@ -503,6 +504,37 @@ model PurchaseRequest {
@@map("purchase_requests") @@map("purchase_requests")
} }
model ExpenseClaim {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
claimNumber String @unique
items Json?
totalAmount Decimal? @db.Decimal(12, 2)
expenseDate DateTime? @db.Date
amount Decimal? @db.Decimal(12, 2)
description String?
projectOrTender String?
status String @default("PENDING")
approvedBy String?
approvedAt DateTime?
rejectedReason String?
approvalNote String?
isPaid Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("expense_claims")
}
model LeaveEntitlement { model LeaveEntitlement {
id String @id @default(uuid()) id String @id @default(uuid())
employeeId String employeeId String
@@ -889,6 +921,7 @@ model Invoice {
model Tender { model Tender {
id String @id @default(uuid()) id String @id @default(uuid())
tenderNumber String @unique tenderNumber String @unique
issueNumber String?
issuingBodyName String issuingBodyName String
title String title String
termsValue Decimal @db.Decimal(15, 2) termsValue Decimal @db.Decimal(15, 2)
@@ -911,6 +944,7 @@ model Tender {
attachments Attachment[] attachments Attachment[]
convertedDeal Deal? convertedDeal Deal?
@@index([tenderNumber]) @@index([tenderNumber])
@@index([issueNumber])
@@index([status]) @@index([status])
@@index([createdById]) @@index([createdById])
@@index([announcementDate]) @@index([announcementDate])
@@ -1594,3 +1628,12 @@ model Approval {
@@map("approvals") @@map("approvals")
} }
model SystemSetting {
key String @id
value String
category String @default("general")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_settings")
}

View File

@@ -1,4 +1,5 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path';
dotenv.config(); dotenv.config();
@@ -33,10 +34,10 @@ export const config = {
}, },
upload: { upload: {
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10),
path: process.env.UPLOAD_PATH || './uploads', path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'),
}, },
pagination: { pagination: {
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10), defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10), maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),

View File

@@ -35,6 +35,7 @@ class ContactsController {
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);
@@ -241,6 +242,7 @@ class ContactsController {
category: req.query.category as string, category: req.query.category as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined, rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true', excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
excludeSuppliers: req.query.excludeSuppliers === 'true',
}; };
const buffer = await contactsService.export(filters); const buffer = await contactsService.export(filters);

View File

@@ -43,7 +43,7 @@ router.post(
authorize('contacts', 'contacts', 'create'), authorize('contacts', 'contacts', 'create'),
[ [
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES', body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]), 'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION','SUPPLIER',]),
body('name').notEmpty().trim(), body('name').notEmpty().trim(),
body('email').optional().isEmail(), body('email').optional().isEmail(),
body('source').notEmpty(), body('source').notEmpty(),
@@ -73,6 +73,7 @@ router.put(
'UN', 'UN',
'NGO', 'NGO',
'INSTITUTION', 'INSTITUTION',
'SUPPLIER',
]), ]),
body('email') body('email')
.optional({ values: 'falsy' }) .optional({ values: 'falsy' })

View File

@@ -43,6 +43,7 @@ interface SearchFilters {
createdFrom?: Date; createdFrom?: Date;
createdTo?: Date; createdTo?: Date;
excludeCompanyEmployees?: boolean; excludeCompanyEmployees?: boolean;
excludeSuppliers?: boolean;
} }
class ContactsService { class ContactsService {
@@ -166,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) {
@@ -758,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 = [
@@ -772,19 +790,39 @@ class ContactsService {
if (filters.source) where.source = filters.source; if (filters.source) where.source = filters.source;
if (filters.rating) where.rating = filters.rating; if (filters.rating) where.rating = filters.rating;
if (filters.excludeSuppliers) {
notConditions.push(
{ type: 'SUPPLIER' },
{
categories: {
some: {
OR: [
{ name: { in: ['Supplier', 'Suppliers'] } },
{ nameAr: { contains: 'مورد' } },
],
},
},
}
);
}
if (filters.excludeCompanyEmployees) { if (filters.excludeCompanyEmployees) {
const companyEmployeeCategory = await prisma.contactCategory.findFirst({ const companyEmployeeCategory = await prisma.contactCategory.findFirst({
where: { name: 'Company Employee', isActive: true }, where: { name: 'Company Employee', isActive: true },
}); });
if (companyEmployeeCategory) { if (companyEmployeeCategory) {
where.NOT = { notConditions.push({
categories: { categories: {
some: { id: companyEmployeeCategory.id }, some: { id: companyEmployeeCategory.id },
}, },
}; });
} }
} }
if (notConditions.length > 0) {
where.NOT = notConditions;
}
// Fetch all contacts (no pagination for export) // Fetch all contacts (no pagination for export)
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where, where,

View File

@@ -2,8 +2,65 @@ import { Router } from 'express';
import { hrController } from './hr.controller'; import { hrController } from './hr.controller';
import { portalController } from './portal.controller'; import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth'; import { authenticate, authorize } from '../../shared/middleware/auth';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { config } from '../../config';
const router = Router(); const router = Router();
const expenseClaimsUploadDir = path.join(config.upload.path, 'expense-claims');
if (!fs.existsSync(expenseClaimsUploadDir)) {
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
}
const expenseClaimStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
filename: (_req, file, cb) => {
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
// but multer/busboy decode them as latin1 by default. For Arabic
// (or any non-ASCII) filenames this produces mojibake like "ÙÙŠÙØ§...".
// Reverse the misinterpretation: take the latin1 string back to bytes,
// then decode as UTF-8. The service reads `decodedOriginalName` when
// it persists the attachment to the DB.
try {
(file as any).decodedOriginalName = Buffer.from(
file.originalname,
'latin1'
).toString('utf8');
} catch {
(file as any).decodedOriginalName = file.originalname;
}
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
},
});
const expenseClaimUpload = multer({
storage: expenseClaimStorage,
limits: { fileSize: config.upload.maxFileSize },
fileFilter: (_req, file, cb) => {
const allowedTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
if (!allowedTypes.includes(file.mimetype)) {
return cb(
new Error('نوع الملف غير مدعوم. يرجى رفع صورة أو ملف PDF.')
);
}
cb(null, true);
},
});
router.use(authenticate); router.use(authenticate);
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ========== // ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
@@ -11,9 +68,13 @@ router.use(authenticate);
router.get('/portal/me', portalController.getMe); router.get('/portal/me', portalController.getMe);
router.get('/portal/loans', portalController.getMyLoans); router.get('/portal/loans', portalController.getMyLoans);
router.post('/portal/loans', portalController.submitLoanRequest); router.post('/portal/loans', portalController.submitLoanRequest);
router.put('/portal/loans/:id', portalController.updateMyLoan);
router.delete('/portal/loans/:id', portalController.deleteMyLoan);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance); router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves); router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest); router.post('/portal/leaves', portalController.submitLeaveRequest);
router.put('/portal/leaves/:id', portalController.updateMyLeave);
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
router.get( router.get(
'/portal/managed-leaves', '/portal/managed-leaves',
@@ -35,6 +96,8 @@ router.post(
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests); router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest); router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
router.put('/portal/overtime-requests/:attendanceId', portalController.updateMyOvertimeRequest);
router.delete('/portal/overtime-requests/:attendanceId', portalController.deleteMyOvertimeRequest);
router.get( router.get(
'/portal/managed-overtime-requests', '/portal/managed-overtime-requests',
@@ -56,9 +119,69 @@ router.post(
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests); router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest); router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.put('/portal/purchase-requests/:id', portalController.updateMyPurchaseRequest);
router.delete('/portal/purchase-requests/:id', portalController.deleteMyPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance); router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries); router.get('/portal/salaries', portalController.getMySalaries);
router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
router.post(
'/portal/expense-claims',
(req, res, next) => {
// Accept up to 10 files under the form field "attachments".
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
if (error) {
return res.status(400).json({
success: false,
message: error.message || 'تعذر رفع المرفقات',
});
}
next();
});
},
portalController.submitExpenseClaim
);
router.put(
'/portal/expense-claims/:id',
(req, res, next) => {
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
if (error) {
return res.status(400).json({
success: false,
message: error.message || 'تعذر رفع المرفقات',
});
}
next();
});
},
portalController.updateMyExpenseClaim
);
router.delete('/portal/expense-claims/:id', portalController.deleteMyExpenseClaim);
router.get(
'/portal/expense-claims/attachments/:attachmentId/view',
portalController.viewExpenseClaimAttachment
);
router.get('/portal/managed-expense-claims', authorize('department_expense_claims', '*', 'read'), portalController.getManagedExpenseClaims);
router.post(
'/portal/managed-expense-claims/:id/approve',
authorize('department_expense_claims', '*', 'approve'),
portalController.approveManagedExpenseClaim
);
router.post(
'/portal/managed-expense-claims/:id/reject',
authorize('department_expense_claims', '*', 'approve'),
portalController.rejectManagedExpenseClaim
);
router.patch(
'/portal/managed-expense-claims/:id/paid',
authorize('department_expense_claims', '*', 'mark-as-paid'),
portalController.markExpenseClaimPaid
);
// ========== EMPLOYEES ========== // ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);

View File

@@ -313,54 +313,77 @@ class HRService {
// ========== LEAVES ========== // ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) { async createLeaveRequest(data: any, userId: string) {
const allowedLeaveTypes = ['ANNUAL', 'HOURLY']; const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) { if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed'); throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
}
const normalizedLeaveType = String(data.leaveType).toUpperCase();
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const ent = await prisma.leaveEntitlement.findUnique({
where: {
employeeId_year_leaveType: {
employeeId: data.employeeId,
year,
leaveType: normalizedLeaveType,
},
},
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
} }
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw new AppError(400, 'تاريخ أو وقت الإجازة غير صالح');
}
const isInvalidRange = normalizedLeaveType === 'HOURLY'
? endDate <= startDate
: endDate < startDate;
if (isInvalidRange) {
throw new AppError(400, 'وقت/تاريخ النهاية يجب أن يكون بعد البداية');
}
const days = normalizedLeaveType === 'HOURLY'
? 0
: this.calculateLeaveDays(startDate, endDate);
const year = startDate.getFullYear();
if (normalizedLeaveType !== 'HOURLY') {
const ent = await prisma.leaveEntitlement.findUnique({
where: {
employeeId_year_leaveType: {
employeeId: data.employeeId,
year,
leaveType: normalizedLeaveType,
},
},
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
}
const leave = await prisma.leave.create({
data: {
employeeId: data.employeeId,
leaveType: normalizedLeaveType,
startDate,
endDate,
days,
reason: data.reason || undefined,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
return leave;
} }
const leave = await prisma.leave.create({
data: {
...data,
leaveType: normalizedLeaveType,
days,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
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 },

View File

@@ -144,19 +144,65 @@ export class PortalController {
} }
} }
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const data = { const body = { ...req.body };
...req.body, const leaveType = String(body.leaveType || '').toUpperCase();
startDate: new Date(req.body.startDate),
endDate: new Date(req.body.endDate), let startDate: Date;
}; let endDate: Date;
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted')); if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
} catch (error) { startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
next(error); endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
} else {
startDate = new Date(body.startDate);
endDate = new Date(body.endDate);
} }
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return res.status(400).json({
success: false,
message: 'تاريخ أو وقت الإجازة غير صالح - Invalid leave date or time',
});
}
const isInvalidRange = leaveType === 'HOURLY'
? endDate <= startDate
: endDate < startDate;
if (isInvalidRange) {
return res.status(400).json({
success: false,
message: 'وقت/تاريخ النهاية يجب أن يكون بعد البداية - End date/time must be after start date/time',
});
}
const data = {
leaveType,
startDate,
endDate,
reason: body.reason || undefined,
};
const leave = await portalService.submitLeaveRequest(
req.user?.employeeId,
data,
req.user!.id
);
res
.status(201)
.json(
ResponseFormatter.success(
leave,
'تم إرسال طلب الإجازة - Leave request submitted'
)
);
} catch (error) {
next(error);
} }
}
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) { async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
@@ -176,6 +222,146 @@ export class PortalController {
} }
} }
async getMyExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMyExpenseClaims(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const body = { ...req.body };
if (typeof body.items === 'string') {
body.items = JSON.parse(body.items);
}
const files = (req.files as Express.Multer.File[] | undefined) || [];
const data = await portalService.submitExpenseClaim(
req.user?.employeeId,
body,
req.user!.id,
files
);
res
.status(201)
.json(
ResponseFormatter.success(
data,
'تم إرسال كشف المصاريف - Expense claim submitted'
)
);
} catch (error: any) {
if (error.message?.includes('نوع الملف غير مدعوم')) {
return res.status(400).json({
success: false,
message: error.message,
});
}
next(error);
}
}
async viewExpenseClaimAttachment(
req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const attachment = await portalService.getExpenseClaimAttachmentFile(
req.params.attachmentId
);
const encodedFileName = encodeURIComponent(attachment.originalName);
res.setHeader('Content-Type', attachment.mimeType);
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodedFileName}`
);
res.sendFile(attachment.path);
} catch (error) {
next(error);
}
}
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
try {
const status = req.query.status as string | undefined;
const search = req.query.search as string | undefined;
const paid = req.query.paid as string | undefined;
const data = await portalService.getManagedExpenseClaims(
req.user?.employeeId,
status,
search,
paid,
);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { approvalNote } = req.body;
const data = await portalService.approveManagedExpenseClaim(
req.user?.employeeId,
req.params.id,
req.user!.id,
approvalNote
);
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
} catch (error) {
next(error);
}
}
async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const data = await portalService.rejectManagedExpenseClaim(
req.user?.employeeId,
req.params.id,
rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(data, 'تم رفض كشف المصاريف - Expense claim rejected'));
} catch (error) {
next(error);
}
}
async markExpenseClaimPaid(req: AuthRequest, res: Response, next: NextFunction) {
try {
const isPaid = Boolean(req.body?.isPaid);
const data = await portalService.markExpenseClaimPaid(
req.user?.employeeId,
req.params.id,
isPaid,
req.user!.id
);
res.json(
ResponseFormatter.success(
data,
isPaid
? 'تم تعليم كشف المصاريف كمقبوض - Expense claim marked as paid'
: 'تم إلغاء تعليم القبض - Paid mark removed'
)
);
} catch (error) {
next(error);
}
}
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) { async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const month = req.query.month ? parseInt(req.query.month as string) : undefined; const month = req.query.month ? parseInt(req.query.month as string) : undefined;
@@ -195,6 +381,178 @@ export class PortalController {
next(error); next(error);
} }
} }
// ========== PERSONAL EDIT/DELETE (pending only) ==========
async updateMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const body = { ...req.body };
const leaveType = body.leaveType ? String(body.leaveType).toUpperCase() : undefined;
let startDate: Date | undefined;
let endDate: Date | undefined;
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
} else if (body.startDate || body.endDate) {
startDate = body.startDate ? new Date(body.startDate) : undefined;
endDate = body.endDate ? new Date(body.endDate) : undefined;
}
const result = await portalService.updateMyLeave(
req.user?.employeeId,
req.params.id,
{
leaveType,
startDate,
endDate,
reason: body.reason,
},
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الإجازة'));
} catch (error) {
next(error);
}
}
async deleteMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyLeave(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الإجازة'));
} catch (error) {
next(error);
}
}
async updateMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyPurchaseRequest(
req.user?.employeeId,
req.params.id,
req.body,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الشراء'));
} catch (error) {
next(error);
}
}
async deleteMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyPurchaseRequest(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الشراء'));
} catch (error) {
next(error);
}
}
async updateMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyLoan(
req.user?.employeeId,
req.params.id,
req.body,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب القرض'));
} catch (error) {
next(error);
}
}
async deleteMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyLoan(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب القرض'));
} catch (error) {
next(error);
}
}
async updateMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.updateMyOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
{ hours: req.body.hours, reason: req.body.reason },
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async deleteMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async updateMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const body = { ...req.body };
if (typeof body.items === 'string') {
body.items = JSON.parse(body.items);
}
if (typeof body.removeAttachmentIds === 'string') {
try {
body.removeAttachmentIds = JSON.parse(body.removeAttachmentIds);
} catch {
body.removeAttachmentIds = [];
}
}
const files = (req.files as Express.Multer.File[] | undefined) || [];
const data = await portalService.updateMyExpenseClaim(
req.user?.employeeId,
req.params.id,
body,
req.user!.id,
files
);
res.json(ResponseFormatter.success(data, 'تم تعديل كشف المصاريف'));
} catch (error: any) {
if (error.message?.includes('نوع الملف غير مدعوم')) {
return res.status(400).json({ success: false, message: error.message });
}
next(error);
}
}
async deleteMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.deleteMyExpenseClaim(
req.user?.employeeId,
req.params.id,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم حذف كشف المصاريف'));
} catch (error) {
next(error);
}
}
} }
export const portalController = new PortalController(); export const portalController = new PortalController();

View File

@@ -1,6 +1,35 @@
import prisma from '../../config/database'; import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler'; import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service'; import { hrService } from './hr.service';
import { notificationsService } from '../notifications/notifications.service';
import path from 'path';
import fs from 'fs';
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
// byte in 0xC2-0xDF followed by a continuation byte in 0x80-0xBF, which is
// exactly what we look for here. Properly-stored Arabic text has code
// points U+0600-U+06FF and won't match this pattern, so the check is safe.
const MOJIBAKE_PATTERN = /[\u00C2-\u00DF][\u0080-\u00BF]/;
/**
* Heuristically repair a string that was stored after multer interpreted
* the file's UTF-8 filename bytes as latin1. Returns the input unchanged
* if it doesn't look like mojibake or if re-decoding would lose data.
*/
function repairFilenameEncoding(value: string | null | undefined): string {
if (!value) return value ?? '';
if (!MOJIBAKE_PATTERN.test(value)) return value;
try {
const decoded = Buffer.from(value, 'latin1').toString('utf8');
// If the re-decode produced replacement chars, the original wasn't
// actually mojibake — bail out and keep the existing value.
if (decoded.includes('\uFFFD')) return value;
return decoded;
} catch {
return value;
}
}
class PortalService { class PortalService {
private requireEmployeeId(employeeId: string | undefined): string { private requireEmployeeId(employeeId: string | undefined): string {
@@ -31,10 +60,11 @@ class PortalService {
throw new AppError(404, 'الموظف غير موجود - Employee not found'); throw new AppError(404, 'الموظف غير موجود - Employee not found');
} }
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([ const [loansCount, pendingLeaves, pendingPurchaseRequests, pendingExpenseClaims, leaveBalance] = await Promise.all([
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }), prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }), prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }), prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.expenseClaim.count({ where: { employeeId: empId, status: 'PENDING' } }),
hrService.getLeaveBalance(empId, new Date().getFullYear()), hrService.getLeaveBalance(empId, new Date().getFullYear()),
]); ]);
@@ -44,6 +74,7 @@ class PortalService {
activeLoansCount: loansCount, activeLoansCount: loansCount,
pendingLeavesCount: pendingLeaves, pendingLeavesCount: pendingLeaves,
pendingPurchaseRequestsCount: pendingPurchaseRequests, pendingPurchaseRequestsCount: pendingPurchaseRequests,
pendingExpenseClaimsCount: pendingExpenseClaims,
leaveBalance, leaveBalance,
}, },
}; };
@@ -212,9 +243,78 @@ class PortalService {
}); });
} }
const employeeFullName = `${attendance.employee.firstName} ${attendance.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'overtime_requests',
fallbackEmployeeId: attendance.employeeId,
fallbackToManager: true,
type: 'OVERTIME_REQUEST_SUBMITTED',
title: 'طلب ساعات إضافية جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب ساعات إضافية جديد.`,
entityType: 'OVERTIME_REQUEST',
entityId: attendance.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: attendance.employeeId,
type: 'OVERTIME_REQUEST_CREATED',
title: 'تم إرسال طلب الساعات الإضافية',
message: 'تم إرسال طلب الساعات الإضافية الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
entityType: 'OVERTIME_REQUEST',
entityId: attendance.id,
excludeUserIds: [],
});
return this.formatOvertimeRequest(attendance); return this.formatOvertimeRequest(attendance);
}
private async attachExpenseClaimFiles<T extends { id: string }>(claims: T[]) {
const claimIds = claims.map((claim) => claim.id);
if (claimIds.length === 0) {
return claims.map((claim) => ({ ...claim, attachments: [] }));
} }
const attachments = await prisma.attachment.findMany({
where: {
entityType: 'EXPENSE_CLAIM',
entityId: { in: claimIds },
},
orderBy: { uploadedAt: 'desc' },
});
return claims.map((claim) => ({
...claim,
attachments: attachments
.filter((attachment) => attachment.entityId === claim.id)
.map((attachment) => ({
...attachment,
// Repair mojibake in records uploaded before the multer fix.
originalName: repairFilenameEncoding(attachment.originalName),
})),
}));
}
async getExpenseClaimAttachmentFile(attachmentId: string) {
const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId },
});
if (!attachment || attachment.entityType !== 'EXPENSE_CLAIM') {
throw new AppError(404, 'الملف غير موجود');
}
// Repair mojibake so the Content-Disposition filename* the controller
// generates uses the real Arabic name when opening/downloading.
return {
...attachment,
originalName: repairFilenameEncoding(attachment.originalName),
};
}
async getManagedOvertimeRequests(employeeId: string | undefined) { async getManagedOvertimeRequests(employeeId: string | undefined) {
this.requireEmployeeId(employeeId); this.requireEmployeeId(employeeId);
@@ -302,6 +402,16 @@ class PortalService {
}, },
}); });
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'OVERTIME_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الساعات الإضافية',
message: 'تمت الموافقة على طلب الساعات الإضافية الخاص بك.',
entityType: 'OVERTIME_REQUEST',
entityId: updated.id,
excludeUserIds: [userId],
});
return this.formatOvertimeRequest(updated); return this.formatOvertimeRequest(updated);
} }
@@ -371,6 +481,18 @@ class PortalService {
}, },
}); });
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'OVERTIME_REQUEST_REJECTED',
title: 'تم رفض طلب الساعات الإضافية',
message: rejectedReason?.trim()
? `تم رفض طلب الساعات الإضافية الخاص بك. السبب: ${rejectedReason.trim()}`
: 'تم رفض طلب الساعات الإضافية الخاص بك.',
entityType: 'OVERTIME_REQUEST',
entityId: updated.id,
excludeUserIds: [userId],
});
return this.formatOvertimeRequest(updated); return this.formatOvertimeRequest(updated);
} }
@@ -445,6 +567,406 @@ class PortalService {
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId); return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
} }
private async generateExpenseClaimNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `EC-${year}-`;
const last = await prisma.expenseClaim.findFirst({
where: {
claimNumber: {
startsWith: prefix,
},
},
orderBy: {
createdAt: 'desc',
},
select: {
claimNumber: true,
},
});
let next = 1;
if (last?.claimNumber) {
const parts = last.claimNumber.split('-');
next = parseInt(parts[2] || '0', 10) + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async getMyExpenseClaims(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
const claims = await prisma.expenseClaim.findMany({
where: { employeeId: empId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.attachExpenseClaimFiles(claims);
}
async submitExpenseClaim(
employeeId: string | undefined,
data: {
items?: Array<{
expenseDate?: string;
amount?: number | string;
entityName?: string;
description?: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
},
userId: string,
files?: Express.Multer.File[]
) {
const empId = this.requireEmployeeId(employeeId);
const items = Array.isArray(data.items) ? data.items : [];
const normalizedItems = items
.map((item) => ({
expenseDate: item.expenseDate || '',
amount: Number(item.amount || 0),
entityName: item.entityName?.trim() || '',
description: item.description?.trim() || '',
projectOrTender: item.projectOrTender?.trim() || '',
proofRef: item.proofRef?.trim() || '',
}))
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
if (normalizedItems.length === 0) {
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
}
const claimNumber = await this.generateExpenseClaimNumber();
const totalAmount = normalizedItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const firstItem = normalizedItems[0];
const claim = await prisma.expenseClaim.create({
data: {
claimNumber,
employeeId: empId,
items: normalizedItems as any,
totalAmount,
expenseDate: new Date(firstItem.expenseDate),
amount: totalAmount,
description: data.description?.trim() || null,
projectOrTender: firstItem.projectOrTender || null,
status: 'PENDING',
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (files && files.length > 0) {
await Promise.all(
files.map((file) =>
prisma.attachment.create({
data: {
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
fileName: path.basename(file.path),
originalName: (file as any).decodedOriginalName || file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: 'EXPENSE_CLAIM_ATTACHMENT',
uploadedBy: userId,
},
})
)
);
}
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
await notificationsService.notifyUsersWithPermission({
module: 'department_expense_claims',
resource: '*',
action: 'approve',
type: 'EXPENSE_CLAIM_SUBMITTED',
title: 'كشف مصاريف جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال كشف مصاريف جديد برقم ${claim.claimNumber}.`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_CREATED',
title: 'تم إرسال كشف المصاريف',
message: `تم إرسال كشف المصاريف الخاص بك برقم ${claim.claimNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [],
});
const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]);
return claimWithAttachments;
}
async getManagedExpenseClaims(
employeeId: string | undefined,
status?: string,
search?: string,
paid?: string,
) {
this.requireEmployeeId(employeeId);
const where: any = {};
if (status && status !== 'all') {
where.status = status;
}
if (paid === 'paid') {
where.isPaid = true;
} else if (paid === 'unpaid') {
where.isPaid = false;
}
const trimmedSearch = search?.trim();
if (trimmedSearch) {
where.employee = {
OR: [
{ firstName: { contains: trimmedSearch, mode: 'insensitive' } },
{ lastName: { contains: trimmedSearch, mode: 'insensitive' } },
{ firstNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
{ lastNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
{ uniqueEmployeeId: { contains: trimmedSearch, mode: 'insensitive' } },
],
};
}
const claims = await prisma.expenseClaim.findMany({
where,
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return this.attachExpenseClaimFiles(claims);
}
async approveManagedExpenseClaim(
managerEmployeeId: string | undefined,
claimId: string,
userId: string,
approvalNote?: string,
) {
this.requireEmployeeId(managerEmployeeId);
const existing = await prisma.expenseClaim.findUnique({
where: { id: claimId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!existing) {
throw new AppError(404, 'كشف المصاريف غير موجود');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const claim = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
status: 'APPROVED',
approvedBy: userId,
approvedAt: new Date(),
rejectedReason: null,
approvalNote: approvalNote?.trim()?.slice(0, 1000) || null,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
const note = approvalNote?.trim()?.slice(0, 1000);
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_APPROVED',
title: 'تمت الموافقة على كشف المصاريف',
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.${note ? ` ملاحظة المعتمِد: ${note}` : ''}`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
return claim;
}
async rejectManagedExpenseClaim(
managerEmployeeId: string | undefined,
claimId: string,
rejectedReason: string,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
if (!rejectedReason || !rejectedReason.trim()) {
throw new AppError(400, 'سبب الرفض مطلوب');
}
const existing = await prisma.expenseClaim.findUnique({
where: { id: claimId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!existing) {
throw new AppError(404, 'كشف المصاريف غير موجود');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const claim = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
status: 'REJECTED',
rejectedReason: rejectedReason.trim(),
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
await notificationsService.notifyEmployeeUser({
employeeId: claim.employeeId,
type: 'EXPENSE_CLAIM_REJECTED',
title: 'تم رفض كشف المصاريف',
message: `تم رفض كشف المصاريف الخاص بك برقم ${claim.claimNumber}. السبب: ${rejectedReason.trim()}`,
entityType: 'EXPENSE_CLAIM',
entityId: claim.id,
excludeUserIds: [userId],
});
return claim;
}
async markExpenseClaimPaid(
managerEmployeeId: string | undefined,
claimId: string,
isPaid: boolean,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
const existing = await prisma.expenseClaim.findUnique({
where: { id: claimId },
select: { id: true, status: true, isPaid: true, claimNumber: true },
});
if (!existing) {
throw new AppError(404, 'كشف المصاريف غير موجود - Expense claim not found');
}
if (existing.status !== 'APPROVED') {
throw new AppError(
400,
'يمكن تعليم القبض فقط على الكشوف المعتمدة - Only approved claims can be marked as paid'
);
}
if (existing.isPaid === isPaid) {
// Idempotent: no change needed.
const claim = await prisma.expenseClaim.findUnique({
where: { id: claimId },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
return claim;
}
const claim = await prisma.expenseClaim.update({
where: { id: claimId },
data: { isPaid },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
return claim;
}
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) { async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
const empId = this.requireEmployeeId(employeeId); const empId = this.requireEmployeeId(employeeId);
const now = new Date(); const now = new Date();
@@ -461,6 +983,376 @@ class PortalService {
take: 24, take: 24,
}); });
} }
// ========== PERSONAL PORTAL EDIT/DELETE (PENDING-only) ==========
// These actions are restricted to the request owner and only while the
// request is still in its initial pending state.
// ---------- Leaves ----------
async updateMyLeave(
employeeId: string | undefined,
leaveId: string,
data: { leaveType?: string; startDate?: Date; endDate?: Date; reason?: string },
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
if (leave.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
// Delete and re-create through the normal validated path so we
// benefit from leave-balance checks and audit logging.
await prisma.leave.delete({ where: { id: leaveId } });
return hrService.createLeaveRequest(
{
employeeId: empId,
leaveType: data.leaveType ?? leave.leaveType,
startDate: data.startDate ?? leave.startDate,
endDate: data.endDate ?? leave.endDate,
reason: data.reason !== undefined ? data.reason : leave.reason || undefined,
},
userId
);
}
async deleteMyLeave(employeeId: string | undefined, leaveId: string, _userId: string) {
const empId = this.requireEmployeeId(employeeId);
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
if (leave.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.leave.delete({ where: { id: leaveId } });
return { success: true };
}
// ---------- Purchase requests ----------
async updateMyPurchaseRequest(
employeeId: string | undefined,
requestId: string,
data: { items?: any[]; reason?: string; priority?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const items = Array.isArray(data.items) ? data.items : (existing.items as any[]) || [];
const totalAmount = items.reduce(
(s: number, i: any) =>
s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)),
0
);
return prisma.purchaseRequest.update({
where: { id: requestId },
data: {
items,
totalAmount,
reason: data.reason !== undefined ? data.reason : existing.reason,
priority: data.priority ?? existing.priority,
},
});
}
async deleteMyPurchaseRequest(
employeeId: string | undefined,
requestId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.purchaseRequest.delete({ where: { id: requestId } });
return { success: true };
}
// ---------- Loans ----------
async updateMyLoan(
employeeId: string | undefined,
loanId: string,
data: { type?: string; amount?: number; installments?: number; reason?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING_HR') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const amount = data.amount !== undefined ? Number(data.amount) : Number(existing.amount);
const installments =
data.installments !== undefined ? Number(data.installments) : existing.installments;
const monthlyAmount = installments > 0 ? amount / installments : amount;
return prisma.loan.update({
where: { id: loanId },
data: {
type: data.type ?? existing.type,
amount,
installments,
monthlyAmount,
reason: data.reason !== undefined ? data.reason : existing.reason,
},
});
}
async deleteMyLoan(employeeId: string | undefined, loanId: string, _userId: string) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING_HR') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
await prisma.loan.delete({ where: { id: loanId } });
return { success: true };
}
// ---------- Overtime requests (stored as attendance rows) ----------
async updateMyOvertimeRequest(
employeeId: string | undefined,
attendanceId: string,
data: { hours?: number; reason?: string },
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
if (att.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
const parsed = this.parseOvertimeRequestNote(att.notes);
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
if (parsed.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const hours = data.hours !== undefined ? Number(data.hours) : parsed.hours;
const reason = data.reason !== undefined ? data.reason : parsed.reason;
if (!hours || hours <= 0) throw new AppError(400, 'عدد الساعات غير صالح');
if (!reason || !String(reason).trim()) throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
const updatedNote = this.buildOvertimeRequestNote(hours, String(reason).trim(), 'PENDING');
const updated = await prisma.attendance.update({
where: { id: attendanceId },
data: { overtimeHours: hours, notes: updatedNote },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
return this.formatOvertimeRequest(updated);
}
async deleteMyOvertimeRequest(
employeeId: string | undefined,
attendanceId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
if (att.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
const parsed = this.parseOvertimeRequestNote(att.notes);
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
if (parsed.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
// Clear the overtime request markers but keep the attendance row intact.
await prisma.attendance.update({
where: { id: attendanceId },
data: { overtimeHours: 0, notes: null },
});
return { success: true };
}
// ---------- Expense claims ----------
async updateMyExpenseClaim(
employeeId: string | undefined,
claimId: string,
data: {
items?: Array<{
expenseDate?: string;
amount?: number | string;
entityName?: string;
description?: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
removeAttachmentIds?: string[];
},
userId: string,
newFiles?: Express.Multer.File[]
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
}
const items = Array.isArray(data.items) ? data.items : [];
const normalizedItems = items
.map((item) => ({
expenseDate: item.expenseDate || '',
amount: Number(item.amount || 0),
entityName: item.entityName?.trim() || '',
description: item.description?.trim() || '',
projectOrTender: item.projectOrTender?.trim() || '',
proofRef: item.proofRef?.trim() || '',
}))
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
if (normalizedItems.length === 0) {
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
}
const totalAmount = normalizedItems.reduce(
(sum, item) => sum + Number(item.amount || 0),
0
);
const firstItem = normalizedItems[0];
// Remove selected attachments (DB + file on disk).
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
const attachments = await prisma.attachment.findMany({
where: {
id: { in: data.removeAttachmentIds },
entityType: 'EXPENSE_CLAIM',
entityId: claimId,
},
});
for (const a of attachments) {
try {
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
} catch {
/* swallow */
}
}
await prisma.attachment.deleteMany({
where: { id: { in: data.removeAttachmentIds }, entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
}
const updated = await prisma.expenseClaim.update({
where: { id: claimId },
data: {
items: normalizedItems as any,
totalAmount,
expenseDate: new Date(firstItem.expenseDate),
amount: totalAmount,
description: data.description?.trim() || null,
projectOrTender: firstItem.projectOrTender || null,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (newFiles && newFiles.length > 0) {
await Promise.all(
newFiles.map((file) =>
prisma.attachment.create({
data: {
entityType: 'EXPENSE_CLAIM',
entityId: claimId,
fileName: path.basename(file.path),
originalName: (file as any).decodedOriginalName || file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: 'EXPENSE_CLAIM_ATTACHMENT',
uploadedBy: userId,
},
})
)
);
}
const [withFiles] = await this.attachExpenseClaimFiles([updated]);
return withFiles;
}
async deleteMyExpenseClaim(
employeeId: string | undefined,
claimId: string,
_userId: string
) {
const empId = this.requireEmployeeId(employeeId);
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
if (existing.employeeId !== empId) {
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
}
if (existing.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
}
const attachments = await prisma.attachment.findMany({
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
for (const a of attachments) {
try {
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
} catch {
/* swallow */
}
}
await prisma.attachment.deleteMany({
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
});
await prisma.expenseClaim.delete({ where: { id: claimId } });
return { success: true };
}
} }
export const portalService = new PortalService(); export const portalService = new PortalService();

View File

@@ -0,0 +1,63 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
import { notificationsService } from './notifications.service';
class NotificationsController {
async listMyNotifications(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Number(req.query.page || 1);
const pageSize = Number(req.query.pageSize || 20);
const result = await notificationsService.listMyNotifications(
req.user!.id,
page,
pageSize
);
res.json(
ResponseFormatter.success(result)
);
} catch (error) {
next(error);
}
}
async getUnreadCount(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await notificationsService.getUnreadCount(req.user!.id);
res.json(ResponseFormatter.success(result));
} catch (error) {
next(error);
}
}
async markAsRead(req: AuthRequest, res: Response, next: NextFunction) {
try {
const notification = await notificationsService.markAsRead(
req.params.id,
req.user!.id
);
res.json(
ResponseFormatter.success(notification, 'تم تعليم الإشعار كمقروء - Notification marked as read')
);
} catch (error) {
next(error);
}
}
async markAllAsRead(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await notificationsService.markAllAsRead(req.user!.id);
res.json(
ResponseFormatter.success(result, 'تم تعليم كل الإشعارات كمقروءة - All notifications marked as read')
);
} catch (error) {
next(error);
}
}
}
export const notificationsController = new NotificationsController();

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { authenticate } from '../../shared/middleware/auth';
import { notificationsController } from './notifications.controller';
const router = Router();
router.use(authenticate);
router.get('/my', notificationsController.listMyNotifications);
router.get('/unread-count', notificationsController.getUnreadCount);
router.patch('/:id/read', notificationsController.markAsRead);
router.patch('/read-all', notificationsController.markAllAsRead);
export default router;

View File

@@ -0,0 +1,404 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
type CreateNotificationInput = {
userId: string;
type: string;
title: string;
message: string;
entityType?: string | null;
entityId?: string | null;
};
type NotifyManyInput = {
userIds: string[];
type: string;
title: string;
message: string;
entityType?: string | null;
entityId?: string | null;
excludeUserIds?: string[];
};
class NotificationsService {
async listMyNotifications(userId: string, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [total, notifications] = await Promise.all([
prisma.notification.count({
where: { userId },
}),
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
]);
return {
notifications,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async getUnreadCount(userId: string) {
const count = await prisma.notification.count({
where: {
userId,
isRead: false,
},
});
return { count };
}
async markAsRead(notificationId: string, userId: string) {
const notification = await prisma.notification.findFirst({
where: {
id: notificationId,
userId,
},
});
if (!notification) {
throw new AppError(404, 'الإشعار غير موجود - Notification not found');
}
return prisma.notification.update({
where: { id: notificationId },
data: {
isRead: true,
readAt: new Date(),
},
});
}
async markAllAsRead(userId: string) {
await prisma.notification.updateMany({
where: {
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
return { success: true };
}
async create(input: CreateNotificationInput) {
return prisma.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
message: input.message,
entityType: input.entityType || null,
entityId: input.entityId || null,
},
});
}
async notifyMany(input: NotifyManyInput) {
const excluded = new Set(input.excludeUserIds || []);
const uniqueUserIds = Array.from(
new Set(input.userIds.filter((id) => !!id && !excluded.has(id)))
);
if (uniqueUserIds.length === 0) {
return [];
}
const created = [];
for (const userId of uniqueUserIds) {
const notification = await prisma.notification.create({
data: {
userId,
type: input.type,
title: input.title,
message: input.message,
entityType: input.entityType || null,
entityId: input.entityId || null,
},
});
created.push(notification);
}
return created;
}
async findUserByEmployeeId(employeeId: string) {
return prisma.user.findFirst({
where: {
employeeId,
isActive: true,
},
select: {
id: true,
employeeId: true,
email: true,
username: true,
},
});
}
async findManagerUserByEmployeeId(employeeId: string) {
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
reportingToId: true,
},
});
if (!employee?.reportingToId) {
return null;
}
return prisma.user.findFirst({
where: {
employeeId: employee.reportingToId,
isActive: true,
},
select: {
id: true,
employeeId: true,
email: true,
username: true,
},
});
}
async notifyEmployeeUser(params: {
employeeId: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const user = await this.findUserByEmployeeId(params.employeeId);
if (!user) return null;
return this.notifyMany({
userIds: [user.id],
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async notifyManagerForEmployee(params: {
employeeId: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const managerUser = await this.findManagerUserByEmployeeId(params.employeeId);
if (!managerUser) return null;
return this.notifyMany({
userIds: [managerUser.id],
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async resolveApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
excludeUserIds?: string[];
}) {
const notifyUsers = await this.findUsersWithPermission({
module: params.resource,
resource: 'all',
action: 'notify',
excludeUserIds: params.excludeUserIds,
});
if (notifyUsers.length > 0) {
return notifyUsers;
}
const approveUsers = await this.findUsersWithPermission({
module: params.resource,
resource: 'all',
action: 'approve',
excludeUserIds: params.excludeUserIds,
});
if (approveUsers.length > 0) {
return approveUsers;
}
if (params.fallbackToManager && params.fallbackEmployeeId) {
const managerUser = await this.findManagerUserByEmployeeId(params.fallbackEmployeeId);
if (managerUser) {
return [managerUser];
}
}
return [];
}
async notifyApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const recipients = await this.resolveApprovalRecipients({
resource: params.resource,
fallbackEmployeeId: params.fallbackEmployeeId,
fallbackToManager: params.fallbackToManager,
excludeUserIds: params.excludeUserIds,
});
return this.notifyMany({
userIds: recipients.map((u) => u.id),
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async findUsersWithPermission(params: {
module: string;
resource?: string;
action: string;
departmentId?: string;
excludeUserIds?: string[];
}) {
const users = await prisma.user.findMany({
where: {
isActive: true,
employee: {
status: 'ACTIVE',
},
},
include: {
employee: {
include: {
position: {
include: {
permissions: true,
},
},
department: true,
},
},
userRoles: {
where: {
role: {
isActive: true,
},
},
include: {
role: {
include: {
permissions: true,
},
},
},
},
},
});
const resource = params.resource || '*';
const excluded = new Set(params.excludeUserIds || []);
const matched = users.filter((user: any) => {
if (excluded.has(user.id)) return false;
if (!user.employee) return false;
if (params.departmentId && user.employee.departmentId !== params.departmentId) {
return false;
}
const positionPerms = user.employee?.position?.permissions || [];
const rolePerms = (user.userRoles || []).flatMap((ur: any) => ur.role?.permissions || []);
const allPerms = [...positionPerms, ...rolePerms];
return allPerms.some((perm: any) => {
const moduleMatch = perm.module === params.module;
const resourceMatch =
perm.resource === resource ||
perm.resource === '*' ||
perm.resource === 'all';
const actions = Array.isArray(perm.actions) ? perm.actions : [];
const actionMatch =
actions.includes(params.action) ||
actions.includes('*') ||
actions.includes('all');
return moduleMatch && resourceMatch && actionMatch;
});
});
return matched.map((u: any) => ({
id: u.id,
employeeId: u.employeeId,
username: u.username,
email: u.email,
}));
}
async notifyUsersWithPermission(params: {
module: string;
resource?: string;
action: string;
departmentId?: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const users = await this.findUsersWithPermission({
module: params.module,
resource: params.resource,
action: params.action,
departmentId: params.departmentId,
excludeUserIds: params.excludeUserIds,
});
return this.notifyMany({
userIds: users.map((u) => u.id),
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
}
export const notificationsService = new NotificationsService();

View File

@@ -3,11 +3,169 @@ import { authenticate, authorize } from '../../shared/middleware/auth';
import prisma from '../../config/database'; import prisma from '../../config/database';
import { ResponseFormatter } from '../../shared/utils/responseFormatter'; import { ResponseFormatter } from '../../shared/utils/responseFormatter';
import { AuditLogger } from '../../shared/utils/auditLogger'; import { AuditLogger } from '../../shared/utils/auditLogger';
import { AppError } from '../../shared/middleware/errorHandler';
const router = Router(); const router = Router();
router.use(authenticate); router.use(authenticate);
// ============================================================
// Helpers
// ============================================================
// Convert a "YYYY-MM-DD" or ISO string to a Date at UTC midnight.
// Prisma's @db.Date columns require a real DateTime; a bare "YYYY-MM-DD"
// in JSON gets parsed to "Invalid data" by the validator.
const toDate = (value: unknown): Date | null | undefined => {
if (value === null) return null;
if (value === undefined || value === '') return undefined;
if (value instanceof Date) return value;
if (typeof value !== 'string') return undefined;
// Already ISO?
if (value.includes('T')) {
const d = new Date(value);
return isNaN(d.getTime()) ? undefined : d;
}
// Plain "YYYY-MM-DD"
const d = new Date(`${value}T00:00:00Z`);
return isNaN(d.getTime()) ? undefined : d;
};
// Drop empty strings, normalize known date/number fields, and only keep keys
// that actually exist on the Prisma Task model. Anything extra (e.g. `tags`)
// would otherwise crash Prisma with "Unknown argument".
const sanitizeTaskBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
const out: any = {};
const setIfPresent = (key: string, value: any) => {
if (value === undefined) return;
out[key] = value;
};
// Strings: collapse '' -> null (update) or skip (create)
const strField = (key: string) => {
if (!(key in body)) return;
const v = body[key];
if (v === '' || v === null) {
if (opts.isUpdate) out[key] = null;
return;
}
if (typeof v === 'string') out[key] = v.trim();
};
strField('title');
strField('description');
strField('projectId');
strField('phaseId');
strField('parentId');
strField('assignedToId');
if ('status' in body && body.status) out.status = String(body.status);
if ('priority' in body && body.priority) out.priority = String(body.priority);
if ('progress' in body) {
const n = Number(body.progress);
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
}
if ('startDate' in body) {
const d = toDate(body.startDate);
if (d !== undefined) out.startDate = d;
}
if ('dueDate' in body) {
const d = toDate(body.dueDate);
if (d !== undefined) out.dueDate = d;
}
if ('completedDate' in body) {
const d = toDate(body.completedDate);
if (d !== undefined) out.completedDate = d;
}
if ('estimatedHours' in body) {
const v = body.estimatedHours;
if (v === '' || v === null) {
if (opts.isUpdate) out.estimatedHours = null;
} else {
const n = Number(v);
if (!Number.isNaN(n) && n >= 0) out.estimatedHours = n;
}
}
if ('actualHours' in body) {
const v = body.actualHours;
if (v === '' || v === null) {
if (opts.isUpdate) out.actualHours = null;
} else {
const n = Number(v);
if (!Number.isNaN(n) && n >= 0) out.actualHours = n;
}
}
// dependencies is Json? in the schema
if ('dependencies' in body) out.dependencies = body.dependencies;
return out;
};
const sanitizeProjectBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
const out: any = {};
const strField = (key: string) => {
if (!(key in body)) return;
const v = body[key];
if (v === '' || v === null) {
if (opts.isUpdate) out[key] = null;
return;
}
if (typeof v === 'string') out[key] = v.trim();
};
// Required-ish strings
if ('name' in body && body.name) out.name = String(body.name).trim();
if ('type' in body && body.type) out.type = String(body.type);
strField('description');
strField('dealId');
strField('clientId');
if ('status' in body && body.status) out.status = String(body.status);
if ('priority' in body && body.priority) out.priority = String(body.priority);
if ('progress' in body) {
const n = Number(body.progress);
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
}
if ('startDate' in body) {
const d = toDate(body.startDate);
if (d !== undefined) out.startDate = d;
}
if ('endDate' in body) {
const d = toDate(body.endDate);
if (d !== undefined) out.endDate = d;
}
if ('actualEndDate' in body) {
const d = toDate(body.actualEndDate);
if (d !== undefined) out.actualEndDate = d;
}
const numField = (key: string) => {
if (!(key in body)) return;
const v = body[key];
if (v === '' || v === null) {
if (opts.isUpdate) out[key] = null;
return;
}
const n = Number(v);
if (!Number.isNaN(n)) out[key] = n;
};
numField('estimatedCost');
numField('actualCost');
return out;
};
// ============================================================
// Projects // Projects
// ============================================================
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => { router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
try { try {
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
@@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
notes: true, notes: true,
}, },
}); });
if (!project) {
throw new AppError(404, 'المشروع غير موجود - Project not found');
}
res.json(ResponseFormatter.success(project)); res.json(ResponseFormatter.success(project));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -45,18 +206,30 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => { router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => {
try { try {
const data = sanitizeProjectBody(req.body, { isUpdate: false });
if (!data.name) {
throw new AppError(400, 'اسم المشروع مطلوب - Project name is required');
}
if (!data.type) {
throw new AppError(400, 'نوع المشروع مطلوب - Project type is required');
}
if (!data.startDate) {
throw new AppError(400, 'تاريخ البدء مطلوب - Start date is required');
}
const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { ...req.body, projectNumber }, data: { ...data, projectNumber },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'PROJECT', entityType: 'PROJECT',
entityId: project.id, entityId: project.id,
action: 'CREATE', action: 'CREATE',
userId: (req as any).user.id, userId: (req as any).user.id,
}); });
res.status(201).json(ResponseFormatter.success(project)); res.status(201).json(ResponseFormatter.success(project));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -65,33 +238,141 @@ router.post('/projects', authorize('projects', 'projects', 'create'), async (req
router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => { router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => {
try { try {
const data = sanitizeProjectBody(req.body, { isUpdate: true });
const project = await prisma.project.update({ const project = await prisma.project.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: req.body, data,
}); });
await AuditLogger.log({
entityType: 'PROJECT',
entityId: project.id,
action: 'UPDATE',
userId: (req as any).user.id,
});
res.json(ResponseFormatter.success(project)); res.json(ResponseFormatter.success(project));
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
router.delete(
'/projects/:id',
authorize('projects', 'projects', 'delete'),
async (req, res, next) => {
try {
// Block delete when the project still has tasks / phases / etc.
const counts = await prisma.project.findUnique({
where: { id: req.params.id },
select: {
_count: {
select: {
tasks: true,
phases: true,
members: true,
expenses: true,
},
},
},
});
if (!counts) {
throw new AppError(404, 'المشروع غير موجود - Project not found');
}
const c = counts._count;
if (c.tasks > 0 || c.phases > 0 || c.expenses > 0) {
throw new AppError(
409,
`لا يمكن حذف المشروع - يحتوي على ${c.tasks} مهمة، ${c.phases} مرحلة، ${c.expenses} مصروف. احذفها أولاً.`,
);
}
// Detach members (safe - they're just join rows) then delete
if (c.members > 0) {
await prisma.projectMember.deleteMany({ where: { projectId: req.params.id } });
}
await prisma.project.delete({ where: { id: req.params.id } });
await AuditLogger.log({
entityType: 'PROJECT',
entityId: req.params.id,
action: 'DELETE',
userId: (req as any).user.id,
});
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المشروع - Project deleted'));
} catch (error) {
next(error);
}
},
);
// ============================================================
// Tasks // Tasks
// ============================================================
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => { router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
try { try {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const pageSize = Math.min(
100,
Math.max(1, parseInt(String(req.query.pageSize || '20'), 10) || 20),
);
const where: any = {}; const where: any = {};
if (req.query.projectId) where.projectId = req.query.projectId; if (req.query.projectId) where.projectId = req.query.projectId;
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId; if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
if (req.query.status) where.status = req.query.status; if (req.query.status) where.status = req.query.status;
if (req.query.priority) where.priority = req.query.priority;
const tasks = await prisma.task.findMany({
where, if (req.query.search) {
const q = String(req.query.search);
where.OR = [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ taskNumber: { contains: q, mode: 'insensitive' } },
];
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
include: {
project: true,
assignedTo: { select: { id: true, email: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.task.count({ where }),
]);
res.json(ResponseFormatter.paginated(tasks, total, page, pageSize));
} catch (error) {
next(error);
}
});
router.get('/tasks/:id', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
try {
const task = await prisma.task.findUnique({
where: { id: req.params.id },
include: { include: {
project: true, project: true,
assignedTo: { select: { email: true, username: true } }, assignedTo: { select: { id: true, email: true, username: true } },
phase: true,
parent: true,
children: true,
}, },
orderBy: { createdAt: 'desc' },
}); });
res.json(ResponseFormatter.success(tasks)); if (!task) {
throw new AppError(404, 'المهمة غير موجودة - Task not found');
}
res.json(ResponseFormatter.success(task));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -99,12 +380,18 @@ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, ne
router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => { router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => {
try { try {
const data = sanitizeTaskBody(req.body, { isUpdate: false });
if (!data.title) {
throw new AppError(400, 'عنوان المهمة مطلوب - Task title is required');
}
const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`; const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { ...req.body, taskNumber }, data: { ...data, taskNumber },
include: { project: true, assignedTo: true }, include: { project: true, assignedTo: true },
}); });
// Create notification for assigned user // Create notification for assigned user
if (task.assignedToId) { if (task.assignedToId) {
await prisma.notification.create({ await prisma.notification.create({
@@ -118,7 +405,14 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
}, },
}); });
} }
await AuditLogger.log({
entityType: 'TASK',
entityId: task.id,
action: 'CREATE',
userId: (req as any).user.id,
});
res.status(201).json(ResponseFormatter.success(task)); res.status(201).json(ResponseFormatter.success(task));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -127,15 +421,41 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => { router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => {
try { try {
const data = sanitizeTaskBody(req.body, { isUpdate: true });
const task = await prisma.task.update({ const task = await prisma.task.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: req.body, data,
include: { project: true, assignedTo: true },
}); });
await AuditLogger.log({
entityType: 'TASK',
entityId: task.id,
action: 'UPDATE',
userId: (req as any).user.id,
});
res.json(ResponseFormatter.success(task)); res.json(ResponseFormatter.success(task));
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
export default router; router.delete('/tasks/:id', authorize('projects', 'tasks', 'delete'), async (req, res, next) => {
try {
await prisma.task.delete({ where: { id: req.params.id } });
await AuditLogger.log({
entityType: 'TASK',
entityId: req.params.id,
action: 'DELETE',
userId: (req as any).user.id,
});
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المهمة - Task deleted'));
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,89 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
import { suppliersService } from './suppliers.service';
class SuppliersController {
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const filters = {
search: req.query.search as string,
status: req.query.status as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
category: req.query.category as string,
};
const result = await suppliersService.findAll(filters, page, pageSize);
res.json(ResponseFormatter.paginated(result.suppliers, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async getStats(req: AuthRequest, res: Response, next: NextFunction) {
try {
const stats = await suppliersService.getStats();
res.json(ResponseFormatter.success(stats));
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.findById(req.params.id);
res.json(ResponseFormatter.success(supplier));
} catch (error) {
next(error);
}
}
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.create({ ...req.body, createdById: req.user!.id }, req.user!.id);
res.status(201).json(ResponseFormatter.success(supplier, 'تم إنشاء المورد بنجاح - Supplier created successfully'));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(supplier, 'تم تحديث المورد بنجاح - Supplier updated successfully'));
} catch (error) {
next(error);
}
}
async archive(req: AuthRequest, res: Response, next: NextFunction) {
try {
const supplier = await suppliersService.archive(req.params.id, req.user!.id, req.body.reason);
res.json(ResponseFormatter.success(supplier, 'تم أرشفة المورد بنجاح - Supplier archived successfully'));
} catch (error) {
next(error);
}
}
async export(req: AuthRequest, res: Response, next: NextFunction) {
try {
const filters = {
search: req.query.search as string,
status: req.query.status as string,
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
category: req.query.category as string,
};
const buffer = await suppliersService.export(filters);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=suppliers-${Date.now()}.xlsx`);
res.send(buffer);
} catch (error) {
next(error);
}
}
}
export const suppliersController = new SuppliersController();

View File

@@ -0,0 +1,66 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
import { suppliersController } from './suppliers.controller';
const router = Router();
router.use(authenticate);
router.get('/', authorize('suppliers', 'suppliers', 'read'), suppliersController.findAll);
router.get('/stats', authorize('suppliers', 'suppliers', 'read'), suppliersController.getStats);
router.get('/export', authorize('suppliers', 'suppliers', 'read'), suppliersController.export);
router.get(
'/:id',
authorize('suppliers', 'suppliers', 'read'),
param('id').isUUID(),
validate,
suppliersController.findById
);
router.post(
'/',
authorize('suppliers', 'suppliers', '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('suppliers', 'suppliers', '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('suppliers', 'suppliers', 'archive'),
param('id').isUUID(),
validate,
suppliersController.archive
);
export default router;

View File

@@ -0,0 +1,314 @@
import prisma from '../../config/database';
import { Prisma } from '@prisma/client';
import { AppError } from '../../shared/middleware/errorHandler';
import { contactsService } from '../contacts/contacts.service';
interface SupplierFilters {
search?: string;
status?: string;
rating?: number;
category?: string;
}
interface SupplierContactData {
name?: string;
nameAr?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
companyName?: string;
companyNameAr?: string;
taxNumber?: string;
commercialRegister?: string;
address?: string;
city?: string;
country?: string;
postalCode?: string;
categories?: string[];
tags?: string[];
source?: string;
rating?: number;
status?: string;
customFields?: any;
createdById?: string;
}
class SuppliersService {
private supplierCategoryNames = ['Supplier', 'Suppliers'];
private isSupplierSystemCategory(category: any) {
return (
this.supplierCategoryNames.includes(category?.name) ||
Boolean(category?.nameAr && String(category.nameAr).includes('مورد'))
);
}
private getSupplierCategoryLabels(supplier: any): string[] {
const customFields = supplier.customFields || {};
if (Array.isArray(customFields.supplierCategories)) {
return customFields.supplierCategories
.map((category: any) => String(category || '').trim())
.filter(Boolean);
}
if (customFields.supplierCategory) return [String(customFields.supplierCategory)];
return (supplier.categories || [])
.filter((category: any) => !this.isSupplierSystemCategory(category))
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)
.filter(Boolean);
}
private async ensureSupplierCategory() {
const existing = await prisma.contactCategory.findFirst({
where: {
isActive: true,
OR: [
{ name: { in: this.supplierCategoryNames } },
{ nameAr: { contains: 'مورد' } },
],
},
});
if (existing) return existing;
return prisma.contactCategory.create({
data: {
name: 'Supplier',
nameAr: 'مورّد',
description: 'Supplier / vendor records managed from Supplier Management module',
},
});
}
private supplierCondition(): Prisma.ContactWhereInput {
return {
OR: [
{ type: 'SUPPLIER' },
{
categories: {
some: {
OR: [
{ name: { in: this.supplierCategoryNames } },
{ nameAr: { contains: 'مورد' } },
],
},
},
},
],
};
}
private buildWhere(filters: SupplierFilters = {}): Prisma.ContactWhereInput {
const andConditions: Prisma.ContactWhereInput[] = [
{ archivedAt: null },
this.supplierCondition(),
];
if (filters.search) {
andConditions.push({
OR: [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
{ email: { contains: filters.search, mode: 'insensitive' } },
{ phone: { contains: filters.search } },
{ mobile: { contains: filters.search } },
{ companyName: { contains: filters.search, mode: 'insensitive' } },
{ companyNameAr: { contains: filters.search, mode: 'insensitive' } },
{ taxNumber: { contains: filters.search, mode: 'insensitive' } },
{ commercialRegister: { contains: filters.search, mode: 'insensitive' } },
],
});
}
if (filters.status) andConditions.push({ status: filters.status });
if (filters.rating !== undefined) andConditions.push({ rating: filters.rating });
if (filters.category) {
andConditions.push({
OR: [
{
customFields: {
path: ['supplierCategories'],
array_contains: [filters.category],
} as any,
},
{
customFields: {
path: ['supplierCategory'],
equals: filters.category,
} as any,
},
{
categories: {
some: {
OR: [
{ name: { equals: filters.category, mode: 'insensitive' } },
{ nameAr: { equals: filters.category } },
],
},
},
},
],
});
}
return { AND: andConditions };
}
private isSupplierContact(contact: any) {
return (
contact.type === 'SUPPLIER' ||
contact.categories?.some((category: any) => this.isSupplierSystemCategory(category))
);
}
async findAll(filters: SupplierFilters, page: number = 1, pageSize: number = 20) {
const skip = (page - 1) * pageSize;
const where = this.buildWhere(filters);
const [total, suppliers] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
skip,
take: pageSize,
include: {
categories: true,
parent: { select: { id: true, name: true, type: true } },
createdBy: { select: { id: true, email: true, username: true } },
},
orderBy: { createdAt: 'desc' },
}),
]);
return { suppliers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async findById(id: string) {
const supplier = await prisma.contact.findUnique({
where: { id },
include: {
categories: true,
parent: true,
createdBy: { select: { id: true, email: true, username: true } },
},
});
if (!supplier || supplier.archivedAt || !this.isSupplierContact(supplier)) {
throw new AppError(404, 'المورد غير موجود - Supplier not found');
}
return supplier;
}
async create(data: SupplierContactData, userId: string) {
const supplierCategory = await this.ensureSupplierCategory();
const categories = [supplierCategory.id];
return contactsService.create(
{
...data,
type: 'SUPPLIER',
name: data.name || data.companyName || 'Supplier',
companyName: data.companyName || data.name,
country: data.country || 'Syria',
source: data.source || 'SUPPLIER_MODULE',
categories,
createdById: data.createdById || userId,
},
userId
);
}
async update(id: string, data: SupplierContactData, userId: string) {
const existing = await this.findById(id);
const supplierCategory = await this.ensureSupplierCategory();
const categories = [supplierCategory.id];
return contactsService.update(
id,
{
...data,
type: 'SUPPLIER',
name: data.name || data.companyName || existing.name,
companyName: data.companyName || data.name || existing.companyName || undefined,
categories,
},
userId
);
}
async archive(id: string, userId: string, reason?: string) {
await this.findById(id);
return contactsService.archive(id, userId, reason || 'Archived from Supplier Management');
}
async getStats() {
const [total, active, inactive, blocked] = await Promise.all([
prisma.contact.count({ where: this.buildWhere() }),
prisma.contact.count({ where: this.buildWhere({ status: 'ACTIVE' }) }),
prisma.contact.count({ where: this.buildWhere({ status: 'INACTIVE' }) }),
prisma.contact.count({ where: this.buildWhere({ status: 'BLOCKED' }) }),
]);
return { total, active, inactive, blocked };
}
async export(filters: SupplierFilters): Promise<Buffer> {
const xlsx = require('xlsx');
const suppliers = await prisma.contact.findMany({
where: this.buildWhere(filters),
include: {
categories: true,
createdBy: { select: { username: true, email: true } },
},
orderBy: { createdAt: 'desc' },
});
const exportData = suppliers.map((supplier) => {
const customFields: any = supplier.customFields || {};
const categoryNames = this.getSupplierCategoryLabels(supplier).join(', ');
return {
'Supplier ID': supplier.uniqueContactId,
'Supplier Code': customFields.supplierCode || '',
'Supplier Name': supplier.companyName || supplier.name,
'Supplier Name (Arabic)': supplier.companyNameAr || supplier.nameAr || '',
'Contact Person': supplier.name || '',
'Email': supplier.email || '',
'Phone': supplier.phone || '',
'Mobile': supplier.mobile || '',
'Website': supplier.website || '',
'Tax Number': supplier.taxNumber || '',
'Commercial Register': supplier.commercialRegister || '',
'Categories': categoryNames,
'Payment Terms': customFields.paymentTerms || '',
'Bank Name': customFields.bankName || '',
'Bank Account': customFields.bankAccount || '',
'Address': supplier.address || '',
'City': supplier.city || '',
'Country': supplier.country || '',
'Status': supplier.status,
'Rating': supplier.rating || '',
'Notes': customFields.notes || '',
'Created By': supplier.createdBy?.username || supplier.createdBy?.email || '',
'Created At': supplier.createdAt.toISOString(),
};
});
const worksheet = xlsx.utils.json_to_sheet(exportData);
const workbook = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(workbook, worksheet, 'Suppliers');
worksheet['!cols'] = [
{ wch: 16 }, { wch: 18 }, { wch: 28 }, { wch: 28 }, { wch: 24 }, { wch: 30 },
{ wch: 16 }, { wch: 16 }, { wch: 24 }, { wch: 18 }, { wch: 22 }, { wch: 18 },
{ wch: 18 }, { wch: 22 }, { wch: 26 }, { wch: 30 }, { wch: 16 }, { wch: 16 },
{ wch: 12 }, { wch: 12 }, { wch: 30 }, { wch: 18 }, { wch: 24 },
];
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
}
export const suppliersService = new SuppliersService();

View File

@@ -27,6 +27,20 @@ export class TendersController {
} }
} }
async delete(req: AuthRequest, res: Response, next: NextFunction) {
try {
await tendersService.delete(req.params.id, req.user!.id)
res.json(
ResponseFormatter.success(
true,
'تم حذف المناقصة بنجاح - Tender deleted successfully'
)
)
} catch (error) {
next(error)
}
}
async findAll(req: AuthRequest, res: Response, next: NextFunction) { async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
@@ -180,6 +194,19 @@ export class TendersController {
} }
} }
async getAssignableEmployees(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const employees = await tendersService.getAssignableEmployees();
res.json(ResponseFormatter.success(employees));
} catch (error) {
next(error);
}
}
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) { async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
if (!req.file) { if (!req.file) {
@@ -236,8 +263,12 @@ export class TendersController {
const fs = require('fs') const fs = require('fs')
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
console.error('[tenders.viewAttachment] Resolved path missing at send time', {
attachmentId: req.params.attachmentId,
resolvedPath: file,
})
return res.status(404).json( return res.status(404).json(
ResponseFormatter.error('File not found', 'الملف غير موجود') ResponseFormatter.error('File not found - الملف غير موجود', 'FILE_NOT_FOUND')
) )
} }
@@ -245,7 +276,7 @@ export class TendersController {
return res.sendFile(path.resolve(file)) return res.sendFile(path.resolve(file))
} catch (error) { } catch (error) {
console.error(error) console.error('[tenders.viewAttachment]', error)
next(error) next(error)
} }
} }

View File

@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir), destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => { filename: (_req, file, cb) => {
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_'); // Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
cb(null, `${crypto.randomUUID()}-${safeName}`); // but multer/busboy decode them as latin1 by default. Reverse it so
// Arabic filenames are stored intact in the DB.
try {
const decoded = Buffer.from(file.originalname || '', 'latin1').toString('utf8');
file.originalname = decoded;
} catch {
// keep as-is
}
const extName = path.extname(file.originalname || '') || '';
cb(null, `${crypto.randomUUID()}${extName}`);
}, },
}); });
const upload = multer({ const upload = multer({
@@ -56,6 +65,16 @@ router.get(
tendersController.getDirectiveTypeValues tendersController.getDirectiveTypeValues
); );
// Minimal employee list for directive assignee dropdown.
// Guarded by directive-create permission so users who can issue directives
// can populate the dropdown WITHOUT being granted hr:employees:read
// (which would expose salaries, national IDs, and other sensitive HR data).
router.get(
'/assignable-employees',
authorize('tenders', 'directives', 'create'),
tendersController.getAssignableEmployees
);
router.post( router.post(
'/check-duplicates', '/check-duplicates',
authorize('tenders', 'tenders', 'create'), authorize('tenders', 'tenders', 'create'),
@@ -83,6 +102,7 @@ router.post(
authorize('tenders', 'tenders', 'create'), authorize('tenders', 'tenders', 'create'),
[ [
body('tenderNumber').notEmpty().trim(), body('tenderNumber').notEmpty().trim(),
body('issueNumber').optional().trim(),
body('issuingBodyName').notEmpty().trim(), body('issuingBodyName').notEmpty().trim(),
body('title').notEmpty().trim(), body('title').notEmpty().trim(),
body('termsValue').isNumeric(), body('termsValue').isNumeric(),
@@ -112,6 +132,14 @@ router.put(
tendersController.update tendersController.update
); );
router.delete(
'/:id',
authorize('tenders', 'tenders', 'delete'),
param('id').isUUID(),
validate,
tendersController.delete
);
// Tender history // Tender history
router.get( router.get(
'/:id/history', '/:id/history',

View File

@@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
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'
import { config } from '../../config';
const TENDER_SOURCE_VALUES = [ const TENDER_SOURCE_VALUES = [
@@ -35,6 +36,7 @@ export interface CreateTenderData {
issuingBodyName: string; issuingBodyName: string;
title: string; title: string;
tenderNumber: string; tenderNumber: string;
issueNumber?: string;
termsValue: number; termsValue: number;
bondValue: number; bondValue: number;
@@ -86,8 +88,52 @@ class TendersService {
return `TND-${year}-${seq}`; return `TND-${year}-${seq}`;
} }
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]'; private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]'; private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
private getCompanyTodayDate(): Date {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Riyadh',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date());
const year = parts.find((p) => p.type === 'year')?.value;
const month = parts.find((p) => p.type === 'month')?.value;
const day = parts.find((p) => p.type === 'day')?.value;
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
}
private toDateOnly(value: Date | string | null | undefined): Date | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
}
private getEffectiveTenderStatus(tender: {
status?: string | null;
closingDate?: Date | string | null;
}) {
if (tender.status === 'ACTIVE') {
const closingDate = this.toDateOnly(tender.closingDate);
const today = this.getCompanyTodayDate();
if (closingDate && closingDate < today) {
return 'EXPIRED';
}
}
return tender.status || 'ACTIVE';
}
private extractTenderExtraMeta(notes?: string | null) { private extractTenderExtraMeta(notes?: string | null) {
if (!notes) { if (!notes) {
@@ -125,6 +171,62 @@ class TendersService {
} }
} }
async delete(id: string, userId: string) {
const tender = await prisma.tender.findUnique({
where: { id },
include: {
attachments: true,
directives: {
include: {
attachments: true,
},
},
convertedDeal: {
select: { id: true },
},
},
});
if (!tender) {
throw new AppError(404, 'Tender not found');
}
if (tender.convertedDeal) {
throw new AppError(400, 'Cannot delete tender that has been converted to deal');
}
for (const attachment of tender.attachments || []) {
if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path);
}
}
for (const directive of tender.directives || []) {
for (const attachment of directive.attachments || []) {
if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path);
}
}
}
await prisma.tender.delete({
where: { id },
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: id,
action: 'DELETE',
userId,
changes: {
deletedTenderNumber: tender.tenderNumber,
deletedTitle: tender.title,
},
});
return true;
}
private buildTenderNotes( private buildTenderNotes(
plainNotes?: string | null, plainNotes?: string | null,
extra?: { extra?: {
@@ -152,11 +254,17 @@ class TendersService {
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
} }
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) { private mapTenderExtraFields<T extends {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); notes?: string | null;
bondValue?: any;
status?: string | null;
closingDate?: Date | string | null;
}>(tender: T) {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
return { return {
...tender, ...tender,
status: this.getEffectiveTenderStatus(tender),
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,
@@ -246,6 +354,7 @@ class TendersService {
const tender = await prisma.tender.create({ const tender = await prisma.tender.create({
data: { data: {
tenderNumber, tenderNumber,
issueNumber: data.issueNumber?.trim() || null,
issuingBodyName: data.issuingBodyName.trim(), issuingBodyName: data.issuingBodyName.trim(),
title: data.title.trim(), title: data.title.trim(),
termsValue: data.termsValue, termsValue: data.termsValue,
@@ -285,11 +394,20 @@ class TendersService {
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } }, { tenderNumber: { contains: filters.search, mode: 'insensitive' } },
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
{ title: { contains: filters.search, mode: 'insensitive' } }, { title: { contains: filters.search, mode: 'insensitive' } },
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, { issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
]; ];
} }
if (filters.status) where.status = filters.status; if (filters.status === 'EXPIRED') {
where.status = 'ACTIVE';
where.closingDate = { lt: this.getCompanyTodayDate() };
} else if (filters.status === 'ACTIVE') {
where.status = 'ACTIVE';
where.closingDate = { gte: this.getCompanyTodayDate() };
} else if (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;
@@ -381,6 +499,9 @@ class TendersService {
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
} }
if (data.title !== undefined) updateData.title = data.title.trim(); if (data.title !== undefined) updateData.title = data.title.trim();
if (data.issueNumber !== undefined) {
updateData.issueNumber = data.issueNumber?.trim() || null;
}
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim(); if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue; if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
if (data.bondValue !== undefined || data.initialBondValue !== undefined) { if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
@@ -555,6 +676,27 @@ class TendersService {
return [...DIRECTIVE_TYPE_VALUES]; return [...DIRECTIVE_TYPE_VALUES];
} }
/**
* Returns a minimal employee list (id + names only) for filling the
* directive assignee dropdown. Intentionally does NOT include salary,
* national ID, passport, email, phone, or any other sensitive HR fields
* so this endpoint can be exposed to anyone with directive-create
* permission without leaking HR data.
*/
async getAssignableEmployees() {
return prisma.employee.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
firstName: true,
lastName: true,
firstNameAr: true,
lastNameAr: true,
},
orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }],
});
}
async convertToDeal( async convertToDeal(
tenderId: string, tenderId: string,
data: { contactId: string; pipelineId: string; ownerId?: string }, data: { contactId: string; pipelineId: string; ownerId?: string },
@@ -568,6 +710,9 @@ class TendersService {
if (tender.status === 'CONVERTED_TO_DEAL') { if (tender.status === 'CONVERTED_TO_DEAL') {
throw new AppError(400, 'Tender already converted to deal'); throw new AppError(400, 'Tender already converted to deal');
} }
if (this.getEffectiveTenderStatus(tender) === 'EXPIRED') {
throw new AppError(400, 'Cannot convert expired tender to deal');
}
const pipeline = await prisma.pipeline.findUnique({ const pipeline = await prisma.pipeline.findUnique({
where: { id: data.pipelineId }, where: { id: data.pipelineId },
@@ -630,7 +775,27 @@ 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 absolutePath = path.resolve(file.path);
const fileName = path.basename(absolutePath);
// Verify multer actually wrote the file to disk before recording it.
if (!fs.existsSync(absolutePath)) {
console.error('[tenders.uploadTenderAttachment] Multer reported a file but it does not exist on disk', {
tenderId,
multerPath: file.path,
resolvedPath: absolutePath,
size: file.size,
});
throw new AppError(500, 'File upload failed - فشل رفع الملف');
}
console.log('[tenders.uploadTenderAttachment] File saved', {
tenderId,
path: absolutePath,
size: file.size,
});
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER', entityType: 'TENDER',
@@ -640,11 +805,12 @@ class TendersService {
originalName: file.originalname, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: absolutePath,
category: category || 'ANNOUNCEMENT', category: category || 'ANNOUNCEMENT',
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
@@ -652,6 +818,7 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
@@ -665,8 +832,29 @@ 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 absolutePath = path.resolve(file.path);
const fileName = path.basename(absolutePath);
// Verify multer actually wrote the file to disk before recording it.
if (!fs.existsSync(absolutePath)) {
console.error('[tenders.uploadDirectiveAttachment] Multer reported a file but it does not exist on disk', {
directiveId,
multerPath: file.path,
resolvedPath: absolutePath,
size: file.size,
});
throw new AppError(500, 'File upload failed - فشل رفع الملف');
}
console.log('[tenders.uploadDirectiveAttachment] File saved', {
directiveId,
path: absolutePath,
size: file.size,
});
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
@@ -677,11 +865,12 @@ class TendersService {
originalName: file.originalname, originalName: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: absolutePath,
category: category || 'TASK_FILE', category: category || 'TASK_FILE',
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
entityId: directiveId, entityId: directiveId,
@@ -689,17 +878,62 @@ 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 },
}) });
if (!attachment) throw new AppError(404, 'File not found') if (!attachment) throw new AppError(404, 'File not found');
return attachment.path // Try multiple candidate locations for the file (in order of preference).
// This makes the system resilient to path changes between deploys (e.g.
// when an old DB row has a stale absolute path).
const candidates = [
attachment.path,
path.join(config.upload.path, 'tenders', attachment.fileName),
path.join(config.upload.path, attachment.fileName),
path.join(process.cwd(), 'uploads', 'tenders', attachment.fileName),
]
.filter(Boolean)
.map((p) => path.resolve(String(p)));
const existingPath = candidates.find((p) => fs.existsSync(p));
if (!existingPath) {
console.error('[tenders.getAttachmentFile] File not found on disk', {
attachmentId,
storedPath: attachment.path,
fileName: attachment.fileName,
uploadConfigPath: config.upload.path,
triedCandidates: candidates,
});
throw new AppError(404, 'File not found - الملف غير موجود');
}
// Self-healing: if the file lives at a path other than what's stored,
// update the DB so future lookups are direct.
if (existingPath !== attachment.path) {
console.warn('[tenders.getAttachmentFile] Stored path was stale, updating', {
attachmentId,
oldPath: attachment.path,
newPath: existingPath,
});
try {
await prisma.attachment.update({
where: { id: attachmentId },
data: { path: existingPath },
});
} catch (err) {
// Non-fatal: we still have the resolved path to serve from.
console.error('[tenders.getAttachmentFile] Failed to update stale path', err);
}
}
return existingPath;
} }
async deleteAttachment(attachmentId: string): Promise<void> { async deleteAttachment(attachmentId: string): Promise<void> {
@@ -709,12 +943,10 @@ class TendersService {
if (!attachment) throw new AppError(404, 'File not found') if (!attachment) throw new AppError(404, 'File not found')
// حذف من الديسك
if (attachment.path && fs.existsSync(attachment.path)) { if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path) fs.unlinkSync(attachment.path)
} }
// حذف من DB
await prisma.attachment.delete({ await prisma.attachment.delete({
where: { id: attachmentId }, where: { id: attachmentId },
}) })

View File

@@ -9,6 +9,8 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes'; 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 suppliersRoutes from '../modules/suppliers/suppliers.routes';
const router = Router(); const router = Router();
@@ -17,12 +19,14 @@ 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);
router.use('/projects', projectsRoutes); router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes); router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes); router.use('/tenders', tendersRoutes);
router.use('/notifications', notificationsRoutes);
// API info // API info
router.get('/', (req, res) => { router.get('/', (req, res) => {
@@ -33,6 +37,7 @@ router.get('/', (req, res) => {
modules: [ modules: [
'Auth', 'Auth',
'Contact Management', 'Contact Management',
'Supplier Management',
'CRM', 'CRM',
'HR Management', 'HR Management',
'Inventory & Assets', 'Inventory & Assets',

View File

@@ -34,8 +34,12 @@ services:
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW} JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
JWT_EXPIRES_IN: 7d JWT_EXPIRES_IN: 7d
JWT_REFRESH_EXPIRES_IN: 30d JWT_REFRESH_EXPIRES_IN: 30d
MAX_FILE_SIZE: 52428800
UPLOAD_PATH: /app/uploads
BCRYPT_ROUNDS: 10 BCRYPT_ROUNDS: 10
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000 CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
volumes:
- backend_uploads:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -67,3 +71,5 @@ services:
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
backend_uploads:
driver: local

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -9,13 +9,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [ const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' }, { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, { id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' },
{ id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' },
{ id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' },
{ id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' }, { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' }, { id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, { id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' }, { id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
@@ -28,6 +38,8 @@ const ACTIONS = [
{ id: 'delete', name: 'حذف' }, { id: 'delete', name: 'حذف' },
{ id: 'export', name: 'تصدير' }, { id: 'export', name: 'تصدير' },
{ id: 'approve', name: 'اعتماد' }, { id: 'approve', name: 'اعتماد' },
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
{ id: 'notify', name: 'إشعار' },
{ id: 'merge', name: 'دمج' }, { id: 'merge', name: 'دمج' },
]; ];

View File

@@ -10,13 +10,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [ const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' }, { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, { id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' }, { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' },
{ id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' },
{ id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' },
{ id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' }, { id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' }, { id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' }, { id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' }, { id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' }, { id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
@@ -29,6 +39,8 @@ const ACTIONS = [
{ id: 'delete', name: 'حذف' }, { id: 'delete', name: 'حذف' },
{ id: 'export', name: 'تصدير' }, { id: 'export', name: 'تصدير' },
{ id: 'approve', name: 'اعتماد' }, { id: 'approve', name: 'اعتماد' },
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
{ id: 'notify', name: 'إشعار' },
{ id: 'merge', name: 'دمج' }, { id: 'merge', name: 'دمج' },
]; ];

View File

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

View File

@@ -29,6 +29,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts' import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
import { categoriesAPI, Category } from '@/lib/api/categories' import { categoriesAPI, Category } from '@/lib/api/categories'
import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories'
import ContactForm from '@/components/contacts/ContactForm' import ContactForm from '@/components/contacts/ContactForm'
import ContactImport from '@/components/contacts/ContactImport' import ContactImport from '@/components/contacts/ContactImport'
@@ -78,6 +79,7 @@ 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
@@ -192,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'
} }
@@ -214,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
} }
@@ -231,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)
@@ -477,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>
@@ -582,13 +589,19 @@ function ContactsContent() {
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{getListCompanyName(contact) !== '-' && ( {getListCompanyName(contact) !== '-' ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Building2 className="h-4 w-4 text-gray-400" /> <div className="h-10 w-10 rounded-full bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center text-white font-bold">
<span className="text-sm text-gray-900"> {getListCompanyName(contact).charAt(0).toUpperCase()}
{getListCompanyName(contact)} </div>
</span> <div>
<p className="font-semibold text-gray-900">
{getListCompanyName(contact)}
</p>
</div>
</div> </div>
) : (
<span className="text-sm text-gray-400">-</span>
)} )}
</td> </td>
@@ -609,13 +622,9 @@ function ContactsContent() {
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
{getListContactName(contact).charAt(0)}
</div>
<div> <div>
<p className="font-semibold text-gray-900"> <p className="text-sm text-gray-900">
{getListContactName(contact)} {getListContactName(contact)}
</p> </p>
{getListContactNameAr(contact) && ( {getListContactNameAr(contact) && (
@@ -624,8 +633,7 @@ function ContactsContent() {
</p> </p>
)} )}
</div> </div>
</div> </td>
</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}> <span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
@@ -819,6 +827,7 @@ function ContactsContent() {
if (selectedStatus !== 'all') filters.status = selectedStatus if (selectedStatus !== 'all') filters.status = selectedStatus
if (selectedCategory !== 'all') filters.category = selectedCategory if (selectedCategory !== 'all') filters.category = selectedCategory
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
filters.excludeSuppliers = true
const blob = await contactsAPI.export(filters) const blob = await contactsAPI.export(filters)
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import Image from 'next/image' import Image from 'next/image'
import logoImage from '@/assets/logo.png' import logoImage from '@/assets/logo.png'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
@@ -8,6 +8,7 @@ import { useAuth } from '@/contexts/AuthContext'
import { useLanguage } from '@/contexts/LanguageContext' import { useLanguage } from '@/contexts/LanguageContext'
import LanguageSwitcher from '@/components/LanguageSwitcher' import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { import {
Users, Users,
User, User,
@@ -21,23 +22,229 @@ import {
Settings, Settings,
Bell, Bell,
Shield, Shield,
FileText FileText,
Truck
} from 'lucide-react' } from 'lucide-react'
import { dashboardAPI } from '@/lib/api' import { dashboardAPI, notificationsAPI } from '@/lib/api'
import { portalAPI } from '@/lib/api/portal'
import { hrAdminAPI } from '@/lib/api/hrAdmin'
function DashboardContent() { function DashboardContent() {
const { user, logout, hasPermission } = useAuth() const { user, logout, hasPermission } = useAuth()
const router = useRouter()
const { t, language, dir } = useLanguage() const { t, language, dir } = useLanguage()
const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 })
const [stats, setStats] = useState({
contacts: 0,
activeTasks: 0,
notifications: 0,
})
const [showNotifications, setShowNotifications] = useState(false)
const [notifications, setNotifications] = useState<any[]>([])
const [notificationsLoading, setNotificationsLoading] = useState(false)
const notificationsRef = useRef<HTMLDivElement | null>(null)
const [pendingApprovals, setPendingApprovals] = useState({
managedLeaves: 0,
managedOvertime: 0,
purchaseRequests: 0,
total: 0,
})
useEffect(() => { useEffect(() => {
dashboardAPI.getStats() dashboardAPI
.getStats()
.then((res) => { .then((res) => {
if (res.data?.data) setStats(res.data.data) if (res.data?.data) setStats(res.data.data)
}) })
.catch(() => {}) .catch(() => {})
}, []) }, [])
const loadNotifications = async () => {
setNotificationsLoading(true)
try {
const res = await notificationsAPI.getMy({ page: 1, pageSize: 10 })
const items = res.data?.data?.notifications || []
setNotifications(items)
} catch {
setNotifications([])
} finally {
setNotificationsLoading(false)
}
}
const markNotificationAsRead = async (id: string) => {
try {
await notificationsAPI.markAsRead(id)
setNotifications((prev) =>
prev.map((item) =>
item.id === id
? { ...item, isRead: true, readAt: new Date().toISOString() }
: item
)
)
setStats((prev) => ({
...prev,
notifications: Math.max(0, prev.notifications - 1),
}))
} catch {}
}
const resolveNotificationUrl = (notification: any) => {
if (notification.entityType === 'LEAVE') {
if (notification.type === 'LEAVE_REQUEST_SUBMITTED') {
return '/portal/managed-leaves';
}
return '/portal/leave';
}
if (notification.entityType === 'OVERTIME_REQUEST') {
if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') {
return '/portal/managed-overtime-requests';
}
return '/portal/overtime';
}
if (notification.entityType === 'PURCHASE_REQUEST') {
if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') {
return '/hr?tab=purchases';
}
return '/portal/purchase-requests';
}
if (notification.entityType === 'LOAN') {
if (
notification.type === 'LOAN_REQUEST_SUBMITTED' ||
notification.type === 'LOAN_REQUEST_PENDING_ADMIN'
) {
return '/hr?tab=loans';
}
return '/portal/loans';
}
if (notification.entityType === 'EXPENSE_CLAIM') {
if (notification.entityId) {
if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') {
return `/portal/managed-expense-claims?claimId=${notification.entityId}`;
}
if (
notification.type === 'EXPENSE_CLAIM_CREATED' ||
notification.type === 'EXPENSE_CLAIM_APPROVED' ||
notification.type === 'EXPENSE_CLAIM_REJECTED'
) {
return `/portal/expense-claims?claimId=${notification.entityId}`;
}
// fallback
return `/portal/expense-claims?claimId=${notification.entityId}`;
}
return '/portal/expense-claims';
}
if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') {
if (notification.entityType === 'TENDER' && notification.entityId) {
return `/tenders/${notification.entityId}?tab=directives`;
}
return '/tenders';
}
return '/dashboard';
};
const handleNotificationClick = async (notification: any) => {
try {
if (!notification.isRead) {
await markNotificationAsRead(notification.id)
}
const targetUrl = resolveNotificationUrl(notification)
console.log('🔔 Notification click →', notification)
console.log('➡️ Redirecting to:', targetUrl)
setShowNotifications(false)
router.push(targetUrl)
setTimeout(() => {
window.location.href = targetUrl
}, 100)
} catch (err) {
console.error('Notification click error:', err)
}
}
const handleToggleNotifications = async () => {
const next = !showNotifications
setShowNotifications(next)
if (next) {
await loadNotifications()
}
}
const loadPendingApprovals = async () => {
try {
const [managedLeaves, managedOvertime, purchaseRequests] = await Promise.all([
canViewManagedLeaves
? portalAPI.getManagedLeaves('PENDING')
: Promise.resolve([]),
canViewManagedOvertime
? portalAPI.getManagedOvertimeRequests()
: Promise.resolve([]),
canApproveHr
? hrAdminAPI
.getPurchaseRequests({ status: 'PENDING', page: 1, pageSize: 50 })
.then((r) => r.purchaseRequests || [])
: Promise.resolve([]),
])
const total =
managedLeaves.length +
managedOvertime.length +
purchaseRequests.length
setPendingApprovals({
managedLeaves: managedLeaves.length,
managedOvertime: managedOvertime.length,
purchaseRequests: purchaseRequests.length,
total,
})
} catch {
setPendingApprovals({
managedLeaves: 0,
managedOvertime: 0,
purchaseRequests: 0,
total: 0,
})
}
}
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
notificationsRef.current &&
!notificationsRef.current.contains(event.target as Node)
) {
setShowNotifications(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const allModules = [ const allModules = [
{ {
id: 'contacts', id: 'contacts',
@@ -46,9 +253,19 @@ function DashboardContent() {
icon: Users, icon: Users,
color: 'bg-blue-500', color: 'bg-blue-500',
href: '/contacts', href: '/contacts',
description: 'إدارة العملاء والموردين وجهات الاتصال', description: 'إدارة العملاء وجهات الاتصال',
permission: 'contacts' permission: 'contacts'
}, },
{
id: 'suppliers',
name: 'إدارة الموردين',
nameEn: 'Supplier Management',
icon: Truck,
color: 'bg-emerald-500',
href: '/suppliers',
description: 'إدارة الموردين وبيانات التواصل والاعتماد',
permission: 'suppliers'
},
{ {
id: 'crm', id: 'crm',
name: 'إدارة علاقات العملاء', name: 'إدارة علاقات العملاء',
@@ -135,6 +352,9 @@ function DashboardContent() {
const availableModules = allModules.filter(module => const availableModules = allModules.filter(module =>
hasPermission(module.permission, 'view') hasPermission(module.permission, 'view')
) )
const canViewManagedLeaves = hasPermission('department_leave_requests', 'view')
const canViewManagedOvertime = hasPermission('department_overtime_requests', 'view')
const canApproveHr = hasPermission('hr', 'approve')
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"> <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
@@ -182,12 +402,86 @@ function DashboardContent() {
)} )}
{/* Notifications */} {/* Notifications */}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative"> <div className="relative" ref={notificationsRef}>
<Bell className="h-5 w-5 text-gray-600" /> <button
{stats.notifications > 0 && ( onClick={handleToggleNotifications}
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span> className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative"
title="الإشعارات"
>
<Bell className="h-5 w-5 text-gray-600" />
{stats.notifications > 0 && (
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
</button>
{showNotifications && (
<div className="absolute left-0 mt-2 w-96 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">الإشعارات</h3>
<button
onClick={async () => {
try {
await notificationsAPI.markAllAsRead()
setNotifications((prev) =>
prev.map((item) => ({
...item,
isRead: true,
readAt: new Date().toISOString(),
}))
)
setStats((prev) => ({ ...prev, notifications: 0 }))
} catch {}
}}
className="text-xs text-blue-600 hover:text-blue-700"
>
تعليم الكل كمقروء
</button>
</div>
<div className="max-h-96 overflow-y-auto">
{notificationsLoading ? (
<div className="p-4 text-sm text-gray-500 text-center">
جاري تحميل الإشعارات...
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-sm text-gray-500 text-center">
لا توجد إشعارات
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map((notification) => (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`w-full text-right px-4 py-3 hover:bg-gray-50 transition-colors ${
notification.isRead ? 'bg-white' : 'bg-blue-50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">
{notification.title}
</p>
<p className="text-xs text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-[11px] text-gray-400 mt-2">
{new Date(notification.createdAt).toLocaleString()}
</p>
</div>
{!notification.isRead && (
<span className="mt-1 h-2.5 w-2.5 rounded-full bg-blue-500 flex-shrink-0"></span>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
)} )}
</button> </div>
{/* Settings */} {/* Settings */}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"> <button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
@@ -268,6 +562,65 @@ function DashboardContent() {
</div> </div>
</div> </div>
{/* Pending Approvals */}
{pendingApprovals.total > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 mb-8">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-xl font-bold text-gray-900">بانتظار موافقتك</h3>
<p className="text-sm text-gray-600 mt-1">
كل الطلبات التي تحتاج قرارك الآن
</p>
</div>
<div className="bg-amber-100 text-amber-700 px-4 py-2 rounded-lg font-bold">
{pendingApprovals.total}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{canViewManagedLeaves && (
<button
onClick={() => router.push('/portal/managed-leaves')}
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
>
<p className="text-sm text-gray-500">طلبات إجازات القسم</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{pendingApprovals.managedLeaves}
</p>
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
</button>
)}
{canViewManagedOvertime && (
<button
onClick={() => router.push('/portal/managed-overtime-requests')}
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
>
<p className="text-sm text-gray-500">طلبات الساعات الإضافية</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{pendingApprovals.managedOvertime}
</p>
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
</button>
)}
{canApproveHr && (
<button
onClick={() => router.push('/hr?tab=purchases')}
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
>
<p className="text-sm text-gray-500">طلبات الشراء</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{pendingApprovals.purchaseRequests}
</p>
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
</button>
)}
</div>
</div>
)}
{/* Available Modules */} {/* Available Modules */}
<div className="mb-8"> <div className="mb-8">
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3> <h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal' import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner' import LoadingSpinner from '@/components/LoadingSpinner'
@@ -245,6 +246,8 @@ function EmployeeFormFields({
function HRContent() { function HRContent() {
// State Management // State Management
const router = useRouter()
const searchParams = useSearchParams()
const [employees, setEmployees] = useState<Employee[]>([]) const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -296,7 +299,13 @@ function HRContent() {
const [loadingDepts, setLoadingDepts] = useState(false) const [loadingDepts, setLoadingDepts] = useState(false)
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts // Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees') type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'
const [activeTab, setActiveTab] = useState<HRTab>('employees')
const openTab = (tab: HRTab) => {
setActiveTab(tab)
router.replace(`/hr?tab=${tab}`)
}
const [hierarchy, setHierarchy] = useState<Department[]>([]) const [hierarchy, setHierarchy] = useState<Department[]>([])
const [loadingHierarchy, setLoadingHierarchy] = useState(false) const [loadingHierarchy, setLoadingHierarchy] = useState(false)
@@ -391,6 +400,24 @@ function HRContent() {
} }
}, [activeTab]) }, [activeTab])
useEffect(() => {
const tabParam = searchParams.get('tab') as HRTab | null
const allowedTabs: HRTab[] = [
'employees',
'departments',
'orgchart',
'leaves',
'loans',
'purchases',
'contracts',
]
if (tabParam && allowedTabs.includes(tabParam)) {
setActiveTab(tabParam)
}
}, [searchParams])
// Fetch Employees (with debouncing for search) // Fetch Employees (with debouncing for search)
const fetchEmployees = useCallback(async () => { const fetchEmployees = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -721,7 +748,7 @@ function HRContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav className="flex gap-4"> <nav className="flex gap-4">
<button <button
onClick={() => setActiveTab('employees')} onClick={() => openTab('employees')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'employees' activeTab === 'employees'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -734,7 +761,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('departments')} onClick={() => openTab('departments')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'departments' activeTab === 'departments'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -747,7 +774,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('orgchart')} onClick={() => openTab('orgchart')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'orgchart' activeTab === 'orgchart'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -760,7 +787,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('leaves')} onClick={() => openTab('leaves')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'leaves' activeTab === 'leaves'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -773,7 +800,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('loans')} onClick={() => openTab('loans')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'loans' activeTab === 'loans'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -786,7 +813,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('purchases')} onClick={() => openTab('purchases')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'purchases' activeTab === 'purchases'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'
@@ -799,7 +826,7 @@ function HRContent() {
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('contracts')} onClick={() => openTab('contracts')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${ className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'contracts' activeTab === 'contracts'
? 'border-red-600 text-red-600' ? 'border-red-600 text-red-600'

View File

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

View File

@@ -0,0 +1,789 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
import Modal from '@/components/Modal';
import { useSearchParams } from 'next/navigation';
type ExpenseClaimLine = {
expenseDate: string;
amount: string;
entityName: string;
description: string;
projectOrTender: string;
proofRef: string;
};
type ExpenseClaimFormState = {
items: ExpenseClaimLine[];
description: string;
attachments: File[];
};
const emptyLine = (): ExpenseClaimLine => ({
expenseDate: '',
amount: '',
entityName: '',
description: '',
projectOrTender: '',
proofRef: '',
});
const initialForm: ExpenseClaimFormState = {
items: [emptyLine()],
description: '',
attachments: [],
};
function getStatusLabel(status: string) {
switch (status) {
case 'PENDING':
return 'قيد المراجعة';
case 'APPROVED':
return 'مقبول';
case 'REJECTED':
return 'مرفوض';
default:
return status;
}
}
function getStatusClasses(status: string) {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-700';
}
}
function formatDate(value?: string | null) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString('en-CA');
}
export default function PortalExpenseClaimsPage() {
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseClaimFormState>(initialForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [removeAttachmentIds, setRemoveAttachmentIds] = useState<string[]>([]);
const [existingAttachments, setExistingAttachments] = useState<
Array<{ id: string; originalName?: string; mimeType?: string }>
>([]);
const [statusFilter, setStatusFilter] = useState('all');
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const filteredClaims = useMemo(() => {
return claims.filter((claim) => {
if (statusFilter !== 'all' && claim.status !== statusFilter) return false;
if (paidFilter === 'paid' && !claim.isPaid) return false;
if (paidFilter === 'unpaid' && claim.isPaid) return false;
return true;
});
}, [claims, statusFilter, paidFilter]);
const totalAmount = useMemo(() => {
return form.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
}, [form.items]);
async function loadClaims() {
try {
setLoading(true);
setError(null);
const data = await portalAPI.getExpenseClaims();
setClaims(data);
} catch (err: any) {
setError(err?.response?.data?.message || 'تعذر تحميل كشوف المصاريف');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadClaims();
}, []);
const addItem = () => {
setForm((prev) => ({
...prev,
items: [...prev.items, emptyLine()],
}));
};
const removeItem = (index: number) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== index),
}));
};
const updateItem = (
index: number,
key: keyof ExpenseClaimLine,
value: string
) => {
setForm((prev) => ({
...prev,
items: prev.items.map((item, i) =>
i === index ? { ...item, [key]: value } : item
),
}));
};
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
setRemoveAttachmentIds([]);
setExistingAttachments([]);
};
const openEdit = (claim: ExpenseClaim) => {
setEditingId(claim.id);
const items = Array.isArray(claim.items) && claim.items.length > 0
? claim.items.map((it: any) => ({
expenseDate: it.expenseDate ? String(it.expenseDate).split('T')[0] : '',
amount: String(it.amount ?? ''),
entityName: it.entityName || '',
description: it.description || '',
projectOrTender: it.projectOrTender || '',
proofRef: it.proofRef || '',
}))
: [emptyLine()];
setForm({
items,
description: claim.description || '',
attachments: [],
});
setExistingAttachments(
(claim.attachments || []).map((a) => ({
id: a.id,
originalName: a.originalName,
mimeType: a.mimeType,
}))
);
setRemoveAttachmentIds([]);
setShowModal(true);
};
const handleDelete = async (id: string) => {
if (!confirm('حذف كشف المصاريف؟')) return;
try {
await portalAPI.deleteExpenseClaim(id);
setClaims((prev) => prev.filter((c) => c.id !== id));
} catch (err: any) {
alert(err?.response?.data?.message || 'فشل الحذف');
}
};
async function openAttachment(attachment: any) {
try {
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
const blobUrl = window.URL.createObjectURL(
new Blob([blob], { type: attachment.mimeType })
);
window.open(blobUrl, '_blank');
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, 10000);
} catch (error) {
alert('تعذر فتح المرفق');
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const items = form.items
.map((item) => ({
expenseDate: item.expenseDate,
amount: Number(item.amount || 0),
entityName: item.entityName.trim() || undefined,
description: item.description.trim(),
projectOrTender: item.projectOrTender.trim() || undefined,
proofRef: item.proofRef.trim() || undefined,
}))
.filter(
(item) =>
item.expenseDate &&
item.description &&
Number(item.amount) > 0
);
if (items.length === 0) {
alert('يرجى إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
return;
}
try {
setSubmitting(true);
if (editingId) {
await portalAPI.updateExpenseClaim(editingId, {
items,
description: form.description.trim() || undefined,
attachments: form.attachments,
removeAttachmentIds,
});
} else {
await portalAPI.submitExpenseClaim({
items,
description: form.description.trim() || undefined,
attachments: form.attachments,
});
}
resetForm();
setShowModal(false);
await loadClaims();
} catch (err: any) {
alert(err?.response?.data?.message || 'تعذر إرسال طلب كشف المصاريف');
} finally {
setSubmitting(false);
}
}
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
return (
<div className="space-y-6" dir="rtl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">كشف المصاريف</h1>
<p className="text-sm text-gray-500 mt-1">
يمكنك تقديم كشف مصاريف جديد ومتابعة حالة الطلبات السابقة
</p>
</div>
<button
onClick={() => { resetForm(); setShowModal(true); }}
className="inline-flex items-center rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
>
+ طلب كشف مصاريف
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">إجمالي الطلبات</div>
<div className="mt-2 text-2xl font-bold text-gray-900">
{claims.length}
</div>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">الكل</option>
<option value="PENDING">قيد المراجعة</option>
<option value="APPROVED">مقبول</option>
<option value="REJECTED">مرفوض</option>
</select>
<select
value={paidFilter}
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">القبض: الكل</option>
<option value="paid">مقبوض</option>
<option value="unpaid">غير مقبوض</option>
</select>
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="text-sm text-gray-500">آخر تحديث</div>
<div className="mt-2 text-base font-semibold text-gray-900">
{claims[0]?.updatedAt ? formatDate(claims[0].updatedAt) : '-'}
</div>
</div>
</div>
<div className="rounded-xl border bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">طلباتي</h2>
</div>
{loading ? (
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
) : error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : filteredClaims.length === 0 ? (
<div className="p-6 text-sm text-gray-500">
لا توجد طلبات كشف مصاريف حالياً
</div>
) : (
<div className="divide-y">
{filteredClaims.map((claim) => {
const isSelected = claim.id === claimId;
return (
<div
key={claim.id}
className={`p-5 ${
isSelected ? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg' : ''
}`}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-2 w-full">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-base font-bold text-gray-900">
{claim.claimNumber}
</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
claim.status
)}`}
>
{getStatusLabel(claim.status)}
</span>
{claim.status === 'PENDING' && (
<span className="inline-flex gap-2">
<button
type="button"
onClick={() => openEdit(claim)}
className="text-xs text-teal-600 hover:underline"
>
تعديل
</button>
<button
type="button"
onClick={() => handleDelete(claim.id)}
className="text-xs text-red-600 hover:underline"
>
حذف
</button>
</span>
)}
</div>
{claim.status === 'APPROVED' && claim.approvalNote ? (
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
<span className="font-medium">ملاحظة المعتمِد:</span> {claim.approvalNote}
</div>
) : null}
{claim.status === 'APPROVED' ? (
<div
className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-sm ${
claim.isPaid
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-gray-200 bg-gray-50 text-gray-700'
}`}
>
<input
type="checkbox"
checked={Boolean(claim.isPaid)}
disabled
readOnly
className="h-4 w-4 cursor-not-allowed accent-emerald-600"
/>
<span className="font-medium">
{claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'}
</span>
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
<div>
<span className="font-medium text-gray-800">
إجمالي المبلغ:
</span>{' '}
{claim.totalAmount ?? claim.amount ?? '-'}
</div>
<div>
<span className="font-medium text-gray-800">
تاريخ الإنشاء:
</span>{' '}
{formatDate(claim.createdAt)}
</div>
<div>
<span className="font-medium text-gray-800">
عدد البنود:
</span>{' '}
{Array.isArray(claim.items)
? claim.items.length
: claim.description
? 1
: 0}
</div>
<div>
<span className="font-medium text-gray-800">
آخر تحديث:
</span>{' '}
{formatDate(claim.updatedAt)}
</div>
</div>
{claim.description ? (
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-800">
ملاحظات عامة:
</span>{' '}
{claim.description}
</div>
) : null}
{Array.isArray(claim.items) && claim.items.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">
البنود:
</div>
<div className="space-y-2">
{claim.items.map((item: any, idx: number) => (
<div
key={idx}
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<span className="font-medium">التاريخ:</span>{' '}
{formatDate(item.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{item.amount ?? '-'}
</div>
<div>
<span className="font-medium">اسم الجهة:</span>{' '}
{item.entityName || '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{item.projectOrTender || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">
الأوراق المثبتة:
</span>{' '}
{item.proofRef || '-'}
</div>
{claim.attachments && claim.attachments.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
<div className="space-y-1">
{claim.attachments.map((attachment) => (
<button
key={attachment.id}
type="button"
onClick={() => openAttachment(attachment)}
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
>
{attachment.originalName}
</button>
))}
</div>
</div>
) : null}
<div className="md:col-span-2">
<span className="font-medium">البيان:</span>{' '}
{item.description || '-'}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
<div>
<span className="font-medium">تاريخ المصروف:</span>{' '}
{formatDate(claim.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{claim.amount ?? '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{claim.projectOrTender || '-'}
</div>
<div>
<span className="font-medium">البيان:</span>{' '}
{claim.description || '-'}
</div>
</div>
)}
{claim.status === 'REJECTED' && claim.rejectedReason ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">سبب الرفض:</span>{' '}
{claim.rejectedReason}
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm(); }}
title={editingId ? 'تعديل كشف المصاريف' : 'كشف مصاريف جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">
بنود كشف المصاريف
</label>
<button
type="button"
onClick={addItem}
className="text-teal-600 text-sm hover:underline"
>
+ إضافة بند
</button>
</div>
<div className="space-y-3">
{form.items.map((item, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
تاريخ المصروف
</label>
<input
type="date"
value={item.expenseDate}
onChange={(e) =>
updateItem(index, 'expenseDate', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
المبلغ
</label>
<input
type="number"
min="0"
step="0.01"
placeholder="المبلغ"
value={item.amount}
onChange={(e) =>
updateItem(index, 'amount', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
اسم الجهة
</label>
<input
type="text"
placeholder="اسم الجهة"
value={item.entityName}
onChange={(e) =>
updateItem(index, 'entityName', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
المشروع / المناقصة
</label>
<input
type="text"
placeholder="المشروع / المناقصة"
value={item.projectOrTender}
onChange={(e) =>
updateItem(index, 'projectOrTender', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700">
البيان
</label>
<textarea
placeholder="البيان"
value={item.description}
onChange={(e) =>
updateItem(index, 'description', e.target.value)
}
className="min-h-[90px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
</div>
{form.items.length > 1 && (
<div className="flex justify-end">
<button
type="button"
onClick={() => removeItem(index)}
className="text-sm text-red-600 hover:underline"
>
حذف البند
</button>
</div>
)}
</div>
))}
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
ملاحظات عامة
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({ ...prev, description: e.target.value }))
}
className="min-h-[100px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="أي ملاحظات عامة على الكشف"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
المرفقات
</label>
{editingId && existingAttachments.length > 0 && (
<div className="mb-2 space-y-1">
<div className="text-xs text-gray-500">المرفقات الحالية:</div>
{existingAttachments.map((a) => {
const isRemoved = removeAttachmentIds.includes(a.id);
return (
<div
key={a.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
isRemoved ? 'bg-red-50 border-red-200 text-red-600 line-through' : 'bg-gray-50 text-gray-700'
}`}
>
<span className="truncate">{a.originalName || a.id}</span>
<button
type="button"
onClick={() => {
setRemoveAttachmentIds((prev) =>
isRemoved ? prev.filter((id) => id !== a.id) : [...prev, a.id]
);
}}
className="text-red-600 hover:underline shrink-0 ms-2"
>
{isRemoved ? 'استرجاع' : 'إزالة'}
</button>
</div>
);
})}
</div>
)}
<input
type="file"
multiple
accept="image/*,application/pdf"
onChange={(e) => {
const picked = Array.from(e.target.files || []);
if (picked.length === 0) return;
setForm((prev) => {
const combined = [...prev.attachments, ...picked];
if (combined.length > 10) {
alert('يمكن إرفاق 10 ملفات كحد أقصى');
return { ...prev, attachments: combined.slice(0, 10) };
}
return { ...prev, attachments: combined };
});
// Reset the input so picking the same file again still fires onChange.
e.target.value = '';
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
يمكن اختيار أكثر من ملف (حتى 10 ملفات).
</p>
{form.attachments.length > 0 ? (
<div className="mt-2 space-y-1">
{form.attachments.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center justify-between rounded-lg bg-gray-50 border px-3 py-2 text-sm text-gray-700"
>
<span className="truncate">{file.name}</span>
<button
type="button"
onClick={() =>
setForm((prev) => ({
...prev,
attachments: prev.attachments.filter((_, i) => i !== idx),
}))
}
className="text-red-600 hover:underline shrink-0 ms-2"
>
إزالة
</button>
</div>
))}
</div>
) : null}
</div>
<div className="rounded-lg bg-gray-50 border px-4 py-3 text-sm font-medium text-gray-700">
الإجمالي: {totalAmount.toLocaleString()}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => { setShowModal(false); resetForm(); }}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
إلغاء
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
Building2, Building2,
LogOut, LogOut,
User, User,
FileText,
CheckCircle2, CheckCircle2,
TimerReset, TimerReset,
} from 'lucide-react' } from 'lucide-react'
@@ -26,6 +27,7 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true }, { icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' }, { icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' }, { icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
{ icon: FileText, label: 'كشوف المصاريف', labelEn: 'Expense Claims', href: '/portal/expense-claims' },
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' }, { icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
...(hasPermission('department_overtime_requests', 'view') ...(hasPermission('department_overtime_requests', 'view')
? [{ ? [{
@@ -35,12 +37,20 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
href: '/portal/managed-overtime-requests' href: '/portal/managed-overtime-requests'
}] }]
: []), : []),
...(hasPermission('department_leave_requests', 'view') ...(hasPermission('department_expense_claims', 'view')
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }] ? [{
: []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' }, icon: CheckCircle2,
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' }, label: 'طلبات كشوف المصاريف',
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' }, labelEn: 'Department Expense Claims',
] href: '/portal/managed-expense-claims'
}]
: []),
...(hasPermission('department_leave_requests', 'view')
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
: []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
]
const isActive = (href: string, exact?: boolean) => { const isActive = (href: string, exact?: boolean) => {
if (exact) return pathname === href if (exact) return pathname === href

View File

@@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
const hour = Math.floor(i / 2).toString().padStart(2, '0')
const minute = i % 2 === 0 ? '00' : '30'
return `${hour}:${minute}`
})
const LEAVE_TYPES = [ const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' }, { value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'HOURLY', label: 'إجازة ساعية' }, { value: 'HOURLY', label: 'إجازة ساعية' },
@@ -18,12 +24,35 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' }, REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
} }
const COMPANY_TIME_ZONE = 'Asia/Riyadh'
const COMPANY_UTC_OFFSET = '+03:00'
const toCompanyDateTime = (date: string, time: string) => {
return `${date}T${time}:00${COMPANY_UTC_OFFSET}`
}
const formatCompanyTime = (value: string) => {
return new Date(value).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: COMPANY_TIME_ZONE,
})
}
const formatCompanyDate = (value: string) => {
return new Date(value).toLocaleDateString('ar-SA', {
timeZone: COMPANY_TIME_ZONE,
})
}
export default function PortalLeavePage() { export default function PortalLeavePage() {
const [leaveBalance, setLeaveBalance] = useState<any[]>([]) const [leaveBalance, setLeaveBalance] = useState<any[]>([])
const [leaves, setLeaves] = useState<any[]>([]) const [leaves, setLeaves] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
leaveType: 'ANNUAL', leaveType: 'ANNUAL',
@@ -48,6 +77,66 @@ export default function PortalLeavePage() {
useEffect(() => load(), []) useEffect(() => load(), [])
const resetForm = () => {
setForm({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
setEditingId(null)
}
const openEdit = (l: any) => {
setEditingId(l.id)
if (l.leaveType === 'HOURLY') {
const start = new Date(l.startDate)
const end = new Date(l.endDate)
const dateStr = start.toLocaleDateString('en-CA', { timeZone: COMPANY_TIME_ZONE })
const fmt = (d: Date) =>
d.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: COMPANY_TIME_ZONE,
})
setForm({
leaveType: 'HOURLY',
startDate: '',
endDate: '',
leaveDate: dateStr,
startTime: fmt(start),
endTime: fmt(end),
reason: l.reason || '',
})
} else {
setForm({
leaveType: 'ANNUAL',
startDate: String(l.startDate).split('T')[0],
endDate: String(l.endDate).split('T')[0],
leaveDate: '',
startTime: '',
endTime: '',
reason: l.reason || '',
})
}
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الإجازة؟')) return
try {
await portalAPI.deleteLeaveRequest(id)
toast.success('تم حذف الطلب')
load()
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -80,28 +169,35 @@ export default function PortalLeavePage() {
return return
} }
payload.startDate = `${form.leaveDate}T${form.startTime}:00` payload.leaveDate = form.leaveDate
payload.endDate = `${form.leaveDate}T${form.endTime}:00` payload.startTime = form.startTime
} payload.endTime = form.endTime
payload.startDate = toCompanyDateTime(form.leaveDate, form.startTime)
payload.endDate = toCompanyDateTime(form.leaveDate, form.endTime)
}
setSubmitting(true) setSubmitting(true)
portalAPI.submitLeaveRequest(payload) const action = editingId
? portalAPI.updateLeaveRequest(editingId, payload)
: portalAPI.submitLeaveRequest(payload)
action
.then(() => { .then(() => {
setShowModal(false) setShowModal(false)
setForm({ resetForm()
leaveType: 'ANNUAL', toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
toast.success('تم إرسال طلب الإجازة')
load() load()
}) })
.catch(() => toast.error('فشل إرسال الطلب')) .catch((err: any) => {
const message =
err.response?.data?.message ||
err.response?.data?.error ||
'فشل إرسال الطلب'
console.error('Leave request error:', err.response?.data || err)
toast.error(message)
})
.finally(() => setSubmitting(false)) .finally(() => setSubmitting(false))
} }
@@ -114,7 +210,7 @@ export default function PortalLeavePage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1> <h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button <button
onClick={() => setShowModal(true)} onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700" className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -156,18 +252,38 @@ export default function PortalLeavePage() {
<p className="font-medium"> <p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '} {l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
{l.leaveType === 'HOURLY' {l.leaveType === 'HOURLY'
? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` ? `${formatCompanyTime(l.startDate)} - ${formatCompanyTime(l.endDate)}`
: `${l.days} يوم`} : `${l.days} يوم`}
</p> </p>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')} {formatCompanyDate(l.startDate)} - {formatCompanyDate(l.endDate)}
</p> </p>
</div> </div>
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}> <div className="flex items-center gap-2">
{statusInfo.label} <span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
</span> {statusInfo.label}
</span>
{l.status === 'PENDING' && (
<>
<button
type="button"
onClick={() => openEdit(l)}
className="text-xs text-teal-600 hover:underline"
>
تعديل
</button>
<button
type="button"
onClick={() => handleDelete(l.id)}
className="text-xs text-red-600 hover:underline"
>
حذف
</button>
</>
)}
</div>
</div> </div>
) )
})} })}
@@ -176,7 +292,11 @@ export default function PortalLeavePage() {
</div> </div>
{/* الفورم */} {/* الفورم */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد"> <Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* نوع الإجازة */} {/* نوع الإجازة */}
@@ -238,22 +358,30 @@ export default function PortalLeavePage() {
<div> <div>
<label className="text-sm">من الساعة</label> <label className="text-sm">من الساعة</label>
<input <select
type="time"
value={form.startTime} value={form.startTime}
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
className="border p-2 rounded w-full" className="border p-2 rounded w-full"
/> >
<option value="">اختر الوقت</option>
{TIME_OPTIONS.map((time) => (
<option key={time} value={time}>{time}</option>
))}
</select>
</div> </div>
<div> <div>
<label className="text-sm">إلى الساعة</label> <label className="text-sm">إلى الساعة</label>
<input <select
type="time"
value={form.endTime} value={form.endTime}
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))} onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
className="border p-2 rounded w-full" className="border p-2 rounded w-full"
/> >
<option value="">اختر الوقت</option>
{TIME_OPTIONS.map((time) => (
<option key={time} value={time}>{time}</option>
))}
</select>
</div> </div>
</div> </div>
)} )}
@@ -270,7 +398,7 @@ export default function PortalLeavePage() {
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => setShowModal(false)} onClick={() => { setShowModal(false); resetForm() }}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg" className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
> >
إلغاء إلغاء
@@ -281,7 +409,7 @@ export default function PortalLeavePage() {
disabled={submitting} disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50" className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
> >
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'} {submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button> </button>
</div> </div>

View File

@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' }) const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
useEffect(() => { useEffect(() => {
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
const resetForm = () => {
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
setEditingId(null)
}
const openEdit = (loan: Loan) => {
setEditingId(loan.id)
setForm({
type: loan.type,
amount: String(loan.amount ?? ''),
installments: String(loan.installments ?? '1'),
reason: loan.reason || '',
})
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب القرض؟')) return
try {
await portalAPI.deleteLoanRequest(id)
toast.success('تم الحذف')
setLoans((prev) => prev.filter((l) => l.id !== id))
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const amount = parseFloat(form.amount) const amount = parseFloat(form.amount)
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
} }
setSubmitting(true) setSubmitting(true)
portalAPI.submitLoanRequest({ const payload = {
type: form.type, type: form.type,
amount, amount,
installments: parseInt(form.installments) || 1, installments: parseInt(form.installments) || 1,
reason: form.reason.trim(), reason: form.reason.trim(),
}) }
const action = editingId
? portalAPI.updateLoanRequest(editingId, payload)
: portalAPI.submitLoanRequest(payload)
action
.then((loan) => { .then((loan) => {
setLoans((prev) => [loan, ...prev]) if (editingId) {
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
} else {
setLoans((prev) => [loan, ...prev])
}
setShowModal(false) setShowModal(false)
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' }) resetForm()
toast.success('تم إرسال طلب القرض') toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
}) })
.catch(() => toast.error('فشل إرسال الطلب')) .catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
.finally(() => setSubmitting(false)) .finally(() => setSubmitting(false))
} }
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1> <h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
<button <button
onClick={() => setShowModal(true)} onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700" className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
</div> </div>
)} )}
</div> </div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}> <div className="flex flex-col items-end gap-2">
{statusInfo.label} <span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
</span> {statusInfo.label}
</span>
{loan.status === 'PENDING_HR' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(loan)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(loan.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div> </div>
{loan.rejectedReason && ( {loan.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p> <p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
</div> </div>
)} )}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد"> <Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label> <label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
إلغاء إلغاء
</button> </button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"> <button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'} {submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,575 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { portalAPI, type ExpenseClaim } from '@/lib/api/portal';
import Modal from '@/components/Modal';
import { useAuth } from '@/contexts/AuthContext';
function formatDate(value?: string | null) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString('en-CA');
}
function getStatusLabel(status: string) {
switch (status) {
case 'PENDING':
return 'قيد المراجعة';
case 'APPROVED':
return 'مقبول';
case 'REJECTED':
return 'مرفوض';
default:
return status;
}
}
function getStatusClasses(status: string) {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-700';
}
}
export default function ManagedExpenseClaimsPage() {
const { hasPermission } = useAuth();
const canMarkAsPaid = hasPermission('department_expense_claims', 'mark-as-paid');
const [claims, setClaims] = useState<ExpenseClaim[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('PENDING');
const [paidFilter, setPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [payingId, setPayingId] = useState<string | null>(null);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [selectedClaim, setSelectedClaim] = useState<ExpenseClaim | null>(null);
const [rejectReason, setRejectReason] = useState('');
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');
async function loadClaims(
status = statusFilter,
search = searchQuery,
paid: 'all' | 'paid' | 'unpaid' = paidFilter,
) {
try {
setLoading(true);
const data = await portalAPI.getManagedExpenseClaims(
status === 'all' ? undefined : status,
search.trim() || undefined,
paid,
);
setClaims(data);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تحميل طلبات كشف المصاريف');
} finally {
setLoading(false);
}
}
useEffect(() => {
// Debounce the search so we don't fire a request on every keystroke.
const handle = setTimeout(() => {
loadClaims(statusFilter, searchQuery, paidFilter);
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, searchQuery, paidFilter]);
async function openAttachment(attachment: any) {
try {
const blob = await portalAPI.viewExpenseClaimAttachment(attachment.id);
const blobUrl = window.URL.createObjectURL(
new Blob([blob], { type: attachment.mimeType })
);
window.open(blobUrl, '_blank');
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, 10000);
} catch (error) {
alert('تعذر فتح المرفق');
}
}
async function handleApprove(id: string) {
const note = window.prompt(
'ملاحظة مع الموافقة (اتركها فارغة إذا لا توجد):',
'',
);
if (note === null) return;
try {
setSubmittingId(id);
await portalAPI.approveManagedExpenseClaim(id, note.trim() || undefined);
await loadClaims(statusFilter, searchQuery, paidFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الموافقة');
} finally {
setSubmittingId(null);
}
}
function openRejectModal(claim: ExpenseClaim) {
setSelectedClaim(claim);
setRejectReason('');
setRejectModalOpen(true);
}
async function handleRejectSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedClaim) return;
if (!rejectReason.trim()) {
alert('سبب الرفض مطلوب');
return;
}
try {
setSubmittingId(selectedClaim.id);
await portalAPI.rejectManagedExpenseClaim(
selectedClaim.id,
rejectReason.trim()
);
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
await loadClaims(statusFilter, searchQuery, paidFilter);
} catch (error: any) {
alert(error?.response?.data?.message || 'تعذر تنفيذ الرفض');
} finally {
setSubmittingId(null);
}
}
async function handleTogglePaid(claim: ExpenseClaim, nextValue: boolean) {
if (!canMarkAsPaid) return;
if (payingId) return;
const previousValue = Boolean(claim.isPaid);
// Optimistic update for snappy UX.
setClaims((prev) =>
prev.map((c) => (c.id === claim.id ? { ...c, isPaid: nextValue } : c))
);
setPayingId(claim.id);
try {
const updated = await portalAPI.markExpenseClaimPaid(claim.id, nextValue);
// Sync local state with server response (in case server normalized anything).
setClaims((prev) =>
prev.map((c) =>
c.id === claim.id ? { ...c, isPaid: Boolean(updated?.isPaid) } : c
)
);
} catch (error: any) {
// Rollback on error.
setClaims((prev) =>
prev.map((c) => (c.id === claim.id ? { ...c, isPaid: previousValue } : c))
);
alert(error?.response?.data?.message || 'تعذر تحديث حالة القبض');
} finally {
setPayingId(null);
}
}
return (
<div className="space-y-6" dir="rtl">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">
طلبات كشف المصاريف للقسم
</h1>
<p className="mt-1 text-sm text-gray-500">
يمكنك مراجعة طلبات كشف المصاريف والموافقة عليها أو رفضها
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">بحث عن موظف:</label>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="الاسم أو الرقم الوظيفي"
className="w-64 rounded-lg border border-gray-300 px-3 py-2 pe-8 text-sm"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 end-2 my-auto h-5 w-5 rounded-full text-gray-400 hover:text-gray-700"
aria-label="مسح البحث"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">الحالة:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="PENDING">قيد المراجعة</option>
<option value="APPROVED">مقبول</option>
<option value="REJECTED">مرفوض</option>
<option value="all">الكل</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">القبض:</label>
<select
value={paidFilter}
onChange={(e) => setPaidFilter(e.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="all">الكل</option>
<option value="paid">مقبوض</option>
<option value="unpaid">غير مقبوض</option>
</select>
</div>
</div>
</div>
<div className="rounded-xl border bg-white shadow-sm">
<div className="border-b px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">الطلبات</h2>
</div>
{loading ? (
<div className="p-6 text-sm text-gray-500">جاري التحميل...</div>
) : claims.length === 0 ? (
<div className="p-6 text-sm text-gray-500">
لا توجد طلبات ضمن هذا الفلتر
</div>
) : (
<div className="divide-y">
{claims.map((claim) => {
const employeeName = claim.employee
? `${claim.employee.firstName} ${claim.employee.lastName}`
: '-';
const isBusy = submittingId === claim.id;
const isSelected = claim.id === claimId;
return (
<div
key={claim.id}
className={`p-5 ${
isSelected
? 'bg-yellow-50 ring-2 ring-yellow-300 rounded-lg'
: ''
}`}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-3 w-full">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-base font-bold text-gray-900">
{claim.claimNumber}
</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusClasses(
claim.status
)}`}
>
{getStatusLabel(claim.status)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-sm text-gray-600">
<div>
<span className="font-medium text-gray-800">
الموظف:
</span>{' '}
{employeeName}
</div>
<div>
<span className="font-medium text-gray-800">
الرقم الوظيفي:
</span>{' '}
{claim.employee?.uniqueEmployeeId || '-'}
</div>
<div>
<span className="font-medium text-gray-800">
إجمالي المبلغ:
</span>{' '}
{claim.totalAmount ?? claim.amount ?? '-'}
</div>
<div>
<span className="font-medium text-gray-800">
تاريخ الإنشاء:
</span>{' '}
{formatDate(claim.createdAt)}
</div>
<div>
<span className="font-medium text-gray-800">
عدد البنود:
</span>{' '}
{Array.isArray(claim.items)
? claim.items.length
: claim.description
? 1
: 0}
</div>
<div>
<span className="font-medium text-gray-800">
آخر تحديث:
</span>{' '}
{formatDate(claim.updatedAt)}
</div>
</div>
{claim.description ? (
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-800">
ملاحظات عامة:
</span>{' '}
{claim.description}
</div>
) : null}
{Array.isArray(claim.items) && claim.items.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">
البنود:
</div>
<div className="space-y-2">
{claim.items.map((item: any, idx: number) => (
<div
key={idx}
className="rounded-lg border bg-gray-50 p-3 text-sm text-gray-700"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<span className="font-medium">التاريخ:</span>{' '}
{formatDate(item.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{item.amount ?? '-'}
</div>
<div>
<span className="font-medium">اسم الجهة:</span>{' '}
{item.entityName || '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{item.projectOrTender || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">
الأوراق المثبتة:
</span>{' '}
{item.proofRef || '-'}
</div>
<div className="md:col-span-2">
<span className="font-medium">البيان:</span>{' '}
{item.description || '-'}
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border bg-gray-50 p-3 text-sm text-gray-700">
<div>
<span className="font-medium">تاريخ المصروف:</span>{' '}
{formatDate(claim.expenseDate)}
</div>
<div>
<span className="font-medium">المبلغ:</span>{' '}
{claim.amount ?? '-'}
</div>
<div>
<span className="font-medium">
المشروع / المناقصة:
</span>{' '}
{claim.projectOrTender || '-'}
</div>
<div>
<span className="font-medium">البيان:</span>{' '}
{claim.description || '-'}
</div>
</div>
)}
{claim.attachments && claim.attachments.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-800">المرفقات:</div>
<div className="space-y-1">
{claim.attachments.map((attachment) => (
<a
key={attachment.id}
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
target="_blank"
rel="noreferrer"
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
>
{attachment.originalName}
</a>
))}
</div>
</div>
) : null}
{claim.status === 'REJECTED' && claim.rejectedReason ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<span className="font-medium">سبب الرفض:</span>{' '}
{claim.rejectedReason}
</div>
) : null}
{claim.status === 'APPROVED' && claim.approvalNote ? (
<div className="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">
<span className="font-medium">ملاحظة المعتمِد:</span>{' '}
{claim.approvalNote}
</div>
) : null}
{claim.status === 'APPROVED' ? (
<div
className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-sm ${
claim.isPaid
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-gray-200 bg-gray-50 text-gray-700'
}`}
>
<input
type="checkbox"
id={`paid-${claim.id}`}
checked={Boolean(claim.isPaid)}
disabled={!canMarkAsPaid || payingId === claim.id}
onChange={(e) => handleTogglePaid(claim, e.target.checked)}
className="h-4 w-4 cursor-pointer accent-emerald-600 disabled:cursor-not-allowed"
/>
<label
htmlFor={`paid-${claim.id}`}
className={`font-medium ${canMarkAsPaid ? 'cursor-pointer' : 'cursor-default'}`}
>
{claim.isPaid ? 'مقبوض - تم الدفع' : 'غير مقبوض'}
</label>
{payingId === claim.id && (
<span className="text-xs text-gray-500">جارٍ الحفظ...</span>
)}
{!canMarkAsPaid && (
<span className="text-xs text-gray-500">(للعرض فقط)</span>
)}
</div>
) : null}
{claim.status === 'PENDING' ? (
<div className="flex items-center gap-2 pt-2">
<button
onClick={() => handleApprove(claim.id)}
disabled={isBusy}
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-60"
>
{isBusy ? 'جارٍ التنفيذ...' : 'موافقة'}
</button>
<button
onClick={() => openRejectModal(claim)}
disabled={isBusy}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
>
رفض
</button>
</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<Modal
isOpen={rejectModalOpen}
onClose={() => {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
}}
title="رفض طلب كشف المصاريف"
>
<form onSubmit={handleRejectSubmit} className="space-y-4">
<div className="text-sm text-gray-600">
{selectedClaim ? (
<>
سيتم رفض الطلب:{' '}
<span className="font-semibold">
{selectedClaim.claimNumber}
</span>
</>
) : null}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
سبب الرفض
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="min-h-[120px] w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-red-500"
placeholder="اكتب سبب الرفض"
required
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => {
setRejectModalOpen(false);
setSelectedClaim(null);
setRejectReason('');
}}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
إلغاء
</button>
<button
type="submit"
disabled={!selectedClaim || submittingId === selectedClaim?.id}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
>
{selectedClaim && submittingId === selectedClaim.id
? 'جارٍ التنفيذ...'
: 'تأكيد الرفض'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -8,6 +8,23 @@ import { toast } from 'react-hot-toast'
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react' import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
const COMPANY_TIME_ZONE = 'Asia/Riyadh'
const formatCompanyTime = (value: string) => {
return new Date(value).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: COMPANY_TIME_ZONE,
})
}
const formatCompanyDateTime = (value: string) => {
return new Date(value).toLocaleString('ar-SA', {
timeZone: COMPANY_TIME_ZONE,
})
}
export default function ManagedLeavesPage() { export default function ManagedLeavesPage() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const [leaves, setLeaves] = useState<ManagedLeave[]>([]) const [leaves, setLeaves] = useState<ManagedLeave[]>([])
@@ -136,15 +153,15 @@ export default function ManagedLeavesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" /> <Calendar className="h-4 w-4 text-gray-400" />
<div> <div>
<p>{new Date(leave.startDate).toLocaleString()}</p> <p>{formatCompanyDateTime(leave.startDate)}</p>
<p>{new Date(leave.endDate).toLocaleString()}</p> <p>{formatCompanyDateTime(leave.endDate)}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-gray-900"> <td className="px-6 py-4 text-gray-900">
{leave.leaveType === 'HOURLY' {leave.leaveType === 'HOURLY'
? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` ? `${formatCompanyTime(leave.startDate)} - ${formatCompanyTime(leave.endDate)}`
: `${leave.days} يوم`} : `${leave.days} يوم`}
</td> </td>

View File

@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
date: '', date: '',
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
loadData() loadData()
}, []) }, [])
const resetForm = () => {
setForm({ date: '', hours: '', reason: '' })
setEditingId(null)
}
const openEdit = (item: PortalOvertimeRequest) => {
setEditingId(item.attendanceId || item.id)
setForm({
date: String(item.date).split('T')[0],
hours: String(item.hours ?? ''),
reason: item.reason || '',
})
setOpen(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الساعات الإضافية؟')) return
try {
await portalAPI.deleteOvertimeRequest(id)
toast.success('تم الحذف')
loadData()
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
try { try {
setSubmitting(true) setSubmitting(true)
await portalAPI.submitOvertimeRequest({ if (editingId) {
date: form.date, await portalAPI.updateOvertimeRequest(editingId, {
hours, hours,
reason: form.reason.trim(), reason: form.reason.trim(),
}) })
toast.success('تم إرسال الطلب') toast.success('تم تعديل الطلب')
} else {
await portalAPI.submitOvertimeRequest({
date: form.date,
hours,
reason: form.reason.trim(),
})
toast.success('تم إرسال الطلب')
}
setOpen(false) setOpen(false)
setForm({ date: '', hours: '', reason: '' }) resetForm()
loadData() loadData()
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب') toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
</div> </div>
<button <button
onClick={() => setOpen(true)} onClick={() => { resetForm(); setOpen(true) }}
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700" className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
) : null} ) : null}
</div> </div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}> <div className="flex flex-col items-end gap-2">
{meta.label} <span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
</span> {meta.label}
</span>
{item.status === 'PENDING' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(item)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(item.attendanceId || item.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div> </div>
) )
})} })}
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
)} )}
</div> </div>
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية"> <Modal
isOpen={open}
onClose={() => { setOpen(false); resetForm() }}
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label> <label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
value={form.date} value={form.date}
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg" className="w-full px-3 py-2 border border-gray-300 rounded-lg"
disabled={!!editingId}
required required
/> />
</div> </div>
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => { setOpen(false); resetForm() }}
className="px-4 py-2 border border-gray-300 rounded-lg" className="px-4 py-2 border border-gray-300 rounded-lg"
> >
إلغاء إلغاء
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
disabled={submitting} disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50" className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
> >
{submitting ? 'جارٍ الإرسال...' : 'إرسال'} {submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -5,7 +5,7 @@ import Link from 'next/link'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { portalAPI, type PortalProfile } from '@/lib/api/portal' import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner' import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react' import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2,FileText } from 'lucide-react'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() { export default function PortalDashboardPage() {
@@ -25,7 +25,7 @@ export default function PortalDashboardPage() {
const { employee, stats } = data const { employee, stats } = data
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view') const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
const canViewDepartmentExpenseClaims = hasPermission('department_expense_claims', 'view')
const name = employee.firstNameAr && employee.lastNameAr const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}` ? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}` : `${employee.firstName} ${employee.lastName}`
@@ -89,12 +89,27 @@ export default function PortalDashboardPage() {
</Link> </Link>
</div> </div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">كشوف المصاريف المعلقة</p>
<p className="text-2xl font-bold text-fuchsia-600 mt-1">{stats.pendingExpenseClaimsCount}</p>
</div>
<div className="bg-fuchsia-100 p-3 rounded-lg">
<FileText className="h-6 w-6 text-fuchsia-600" />
</div>
</div>
<Link href="/portal/expense-claims" className="mt-4 text-sm text-fuchsia-600 hover:underline flex items-center gap-1">
عرض الكشوف <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100"> <div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p> <p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
<p className="text-2xl font-bold text-blue-600 mt-1"> <p className="text-2xl font-bold text-blue-600 mt-1">
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount} {stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount + stats.pendingExpenseClaimsCount}
</p> </p>
</div> </div>
<div className="bg-blue-100 p-3 rounded-lg"> <div className="bg-blue-100 p-3 rounded-lg">
@@ -104,6 +119,7 @@ export default function PortalDashboardPage() {
<div className="mt-4 flex gap-4"> <div className="mt-4 flex gap-4">
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link> <Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link> <Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
<Link href="/portal/expense-claims" className="text-sm text-blue-600 hover:underline">المصاريف</Link>
</div> </div>
</div> </div>
@@ -171,6 +187,11 @@ export default function PortalDashboardPage() {
</Link> </Link>
)} )}
<Link href="/portal/expense-claims" className="inline-flex items-center gap-2 px-4 py-2 bg-fuchsia-600 text-white rounded-lg hover:bg-fuchsia-700">
<Plus className="h-4 w-4" />
كشف مصاريف
</Link>
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"> <Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
طلب شراء طلب شراء

View File

@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
items: [{ description: '', quantity: 1, estimatedPrice: '' }], items: [{ description: '', quantity: 1, estimatedPrice: '' }],
reason: '', reason: '',
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)), items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
})) }))
const resetForm = () => {
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
setEditingId(null)
}
const openEdit = (pr: PurchaseRequest) => {
setEditingId(pr.id)
const items = Array.isArray(pr.items) && pr.items.length > 0
? pr.items.map((it: any) => ({
description: String(it.description || ''),
quantity: Number(it.quantity || 1),
estimatedPrice: String(it.estimatedPrice ?? ''),
}))
: [{ description: '', quantity: 1, estimatedPrice: '' }]
setForm({ items, reason: pr.reason || '', priority: pr.priority || 'NORMAL' })
setShowModal(true)
}
const handleDelete = async (id: string) => {
if (!confirm('حذف طلب الشراء؟')) return
try {
await portalAPI.deletePurchaseRequest(id)
toast.success('تم الحذف')
setRequests((prev) => prev.filter((r) => r.id !== id))
} catch (err: any) {
toast.error(err?.response?.data?.message || 'فشل الحذف')
}
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const items = form.items const items = form.items
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
return return
} }
setSubmitting(true) setSubmitting(true)
portalAPI.submitPurchaseRequest({ const payload = { items, reason: form.reason || undefined, priority: form.priority }
items, const action = editingId
reason: form.reason || undefined, ? portalAPI.updatePurchaseRequest(editingId, payload)
priority: form.priority, : portalAPI.submitPurchaseRequest(payload)
}) action
.then((pr) => { .then((pr) => {
setRequests((prev) => [pr, ...prev]) if (editingId) {
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
} else {
setRequests((prev) => [pr, ...prev])
}
setShowModal(false) setShowModal(false)
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' }) resetForm()
toast.success('تم إرسال طلب الشراء') toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
}) })
.catch(() => toast.error('فشل إرسال الطلب')) .catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
.finally(() => setSubmitting(false)) .finally(() => setSubmitting(false))
} }
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1> <h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
<button <button
onClick={() => setShowModal(true)} onClick={() => { resetForm(); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700" className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -121,9 +155,17 @@ export default function PortalPurchaseRequestsPage() {
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p> <p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
)} )}
</div> </div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}> <div className="flex flex-col items-end gap-2">
{statusInfo.label} <span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
</span> {statusInfo.label}
</span>
{pr.status === 'PENDING' && (
<div className="flex gap-2">
<button type="button" onClick={() => openEdit(pr)} className="text-xs text-teal-600 hover:underline">تعديل</button>
<button type="button" onClick={() => handleDelete(pr.id)} className="text-xs text-red-600 hover:underline">حذف</button>
</div>
)}
</div>
</div> </div>
</div> </div>
) )
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
</div> </div>
)} )}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد"> <Modal
isOpen={showModal}
onClose={() => { setShowModal(false); resetForm() }}
title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'}
>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto"> <form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div> <div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
إلغاء إلغاء
</button> </button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"> <button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'} {submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
</button> </button>
</div> </div>
</form> </form>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
'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, Users, 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
// Additional contact persons (stored as a JSON array in customFields).
// Safe to read even if absent / malformed: filter to objects with a name.
const additionalContacts: Array<{
name?: string
position?: string
email?: string
phone?: string
mobile?: string
notes?: string
}> = Array.isArray(customFields.additionalContacts)
? customFields.additionalContacts.filter(
(c: any) => c && typeof c === 'object' && typeof c.name === 'string' && c.name.trim() !== ''
)
: []
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>
{additionalContacts.length > 0 && (
<InfoCard icon={Users} title={`أشخاص تواصل إضافيين (${additionalContacts.length})`}>
<div className="space-y-4">
{additionalContacts.map((contact, index) => (
<div
key={index}
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-700 font-semibold text-sm">
{(contact.name || '?').charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium text-gray-900">{contact.name}</p>
{contact.position && (
<p className="text-xs text-gray-500">{contact.position}</p>
)}
</div>
</div>
</div>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-3">
{contact.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-gray-400 shrink-0" />
<a
href={`mailto:${contact.email}`}
className="text-emerald-700 hover:underline truncate"
>
{contact.email}
</a>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-gray-400 shrink-0" />
<span className="text-gray-900" dir="ltr">{contact.phone}</span>
<span className="text-xs text-gray-500">(الهاتف)</span>
</div>
)}
{contact.mobile && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-gray-400 shrink-0" />
<span className="text-gray-900" dir="ltr">{contact.mobile}</span>
<span className="text-xs text-gray-500">(الموبايل)</span>
</div>
)}
{contact.notes && (
<div className="md:col-span-2 flex items-start gap-2 text-sm">
<FileText className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
<span className="text-gray-700 whitespace-pre-wrap">{contact.notes}</span>
</div>
)}
</dl>
</div>
))}
</div>
</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>
}

View File

@@ -0,0 +1,663 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import { ArrowLeft, BadgeCheck, Building2, CircleDollarSign, Download, Edit, Eye, Filter, Landmark, Loader2,Shield, Mail, Phone, Plus, Search, Star, Tag, Trash2, Truck, X } from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import Modal from '@/components/Modal'
import { useAuth } from '@/contexts/AuthContext'
import SupplierCategorySelector from '@/components/suppliers/SupplierCategorySelector'
import { suppliersAPI, Supplier, SupplierFilters, CreateSupplierData, UpdateSupplierData, SupplierStats } from '@/lib/api/suppliers'
import { DEFAULT_SUPPLIER_CATEGORIES, isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
type SupplierAdditionalContact = {
name: string
position: string
email: string
phone: string
mobile: string
notes: string
}
const emptyAdditionalContact = (): SupplierAdditionalContact => ({
name: '',
position: '',
email: '',
phone: '',
mobile: '',
notes: '',
})
type SupplierFormState = {
companyName: string
companyNameAr: string
name: string
email: string
phone: string
mobile: string
website: string
taxNumber: string
commercialRegister: string
address: string
city: string
country: string
supplierCode: string
supplierCategories: string[]
paymentTerms: string
bankName: string
bankAccount: string
contactPosition: string
notes: string
tags: string
rating: number
status: string
additionalContacts: SupplierAdditionalContact[]
}
function getSupplierCategoryLabels(supplier: Supplier): string[] {
const customFields = supplier.customFields || {}
if (Array.isArray(customFields.supplierCategories)) {
const categories = uniqueSupplierCategories(customFields.supplierCategories)
if (categories.length > 0) return categories
}
if (customFields.supplierCategory) return [String(customFields.supplierCategory)]
return uniqueSupplierCategories((supplier.categories || [])
.filter((category: any) => !isSupplierSystemCategoryName(category.name, category.nameAr))
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name))
}
const buildInitialForm = (supplier?: Supplier): SupplierFormState => {
const customFields = supplier?.customFields || {}
// Hydrate additional contacts from customFields, tolerating bad/missing data.
const rawAdditional = Array.isArray(customFields.additionalContacts)
? customFields.additionalContacts
: []
const additionalContacts: SupplierAdditionalContact[] = rawAdditional.map((c: any) => ({
name: typeof c?.name === 'string' ? c.name : '',
position: typeof c?.position === 'string' ? c.position : '',
email: typeof c?.email === 'string' ? c.email : '',
phone: typeof c?.phone === 'string' ? c.phone : '',
mobile: typeof c?.mobile === 'string' ? c.mobile : '',
notes: typeof c?.notes === 'string' ? c.notes : '',
}))
return {
companyName: supplier?.companyName || supplier?.name || '',
companyNameAr: supplier?.companyNameAr || '',
name: supplier?.name || '',
email: supplier?.email || '',
phone: supplier?.phone || '',
mobile: supplier?.mobile || '',
website: supplier?.website || '',
taxNumber: supplier?.taxNumber || '',
commercialRegister: supplier?.commercialRegister || '',
address: supplier?.address || '',
city: supplier?.city || '',
country: supplier?.country || 'Syria',
supplierCode: customFields.supplierCode || '',
supplierCategories: supplier ? getSupplierCategoryLabels(supplier) : [],
paymentTerms: customFields.paymentTerms || '',
bankName: customFields.bankName || '',
bankAccount: customFields.bankAccount || '',
contactPosition: customFields.contactPosition || '',
notes: customFields.notes || '',
tags: supplier?.tags?.join(', ') || '',
rating: supplier?.rating || 0,
status: supplier?.status || 'ACTIVE',
additionalContacts,
}
}
function renderRating(rating?: number) {
if (!rating) return <span className="text-sm text-gray-400">بدون تقييم</span>
return <div className="flex items-center gap-0.5">{[1, 2, 3, 4, 5].map((star) => <Star key={star} className={`h-4 w-4 ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`} />)}</div>
}
function getSupplierName(supplier: Supplier) {
return supplier.companyName || supplier.name || '-'
}
function getContactPerson(supplier: Supplier) {
const supplierName = getSupplierName(supplier)
if (!supplier.name || supplier.name === supplierName) return '-'
return supplier.name
}
function SupplierForm({ supplier, submitting, availableCategories, onCancel, onSubmit }: {
supplier?: Supplier
submitting?: boolean
availableCategories?: string[]
onCancel: () => void
onSubmit: (data: any) => Promise<void>
}) {
const isEdit = !!supplier
const [form, setForm] = useState<SupplierFormState>(buildInitialForm(supplier))
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
useEffect(() => {
setForm(buildInitialForm(supplier))
setFormErrors({})
}, [supplier])
const updateField = (field: keyof SupplierFormState, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }))
const optional = (value: string) => value.trim() || undefined
// Helpers for additional contact persons.
const updateAdditionalContact = (
index: number,
field: keyof SupplierAdditionalContact,
value: string
) => {
setForm((prev) => {
const next = prev.additionalContacts.map((contact, i) =>
i === index ? { ...contact, [field]: value } : contact
)
return { ...prev, additionalContacts: next }
})
}
const addAdditionalContact = () => {
setForm((prev) => ({
...prev,
additionalContacts: [...prev.additionalContacts, emptyAdditionalContact()],
}))
}
const removeAdditionalContact = (index: number) => {
setForm((prev) => ({
...prev,
additionalContacts: prev.additionalContacts.filter((_, i) => i !== index),
}))
}
const validate = () => {
const errors: Record<string, string> = {}
if (!form.companyName.trim() && !form.name.trim()) errors.companyName = 'اسم المورد أو مسؤول التواصل مطلوب'
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'صيغة البريد الإلكتروني غير صحيحة'
form.additionalContacts.forEach((contact, index) => {
// A row is "filled" if any field has content; once filled, name and a valid email become rules.
const hasAnyValue = Object.values(contact).some((value) => value.trim() !== '')
if (hasAnyValue && !contact.name.trim()) {
errors[`additionalContact_${index}_name`] = 'اسم شخص التواصل مطلوب'
}
if (contact.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email)) {
errors[`additionalContact_${index}_email`] = 'صيغة البريد الإلكتروني غير صحيحة'
}
})
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
if (!validate()) return
const companyName = form.companyName.trim() || form.name.trim()
const contactName = form.name.trim() || companyName
const supplierCategories = uniqueSupplierCategories(form.supplierCategories)
// Drop entirely-empty rows; trim every field on the rest.
const cleanedAdditionalContacts = form.additionalContacts
.map((contact) => ({
name: contact.name.trim(),
position: contact.position.trim(),
email: contact.email.trim(),
phone: contact.phone.trim(),
mobile: contact.mobile.trim(),
notes: contact.notes.trim(),
}))
.filter((contact) => Object.values(contact).some((value) => value !== ''))
const payload: any = {
name: contactName,
companyName,
companyNameAr: optional(form.companyNameAr),
email: optional(form.email),
phone: optional(form.phone),
mobile: optional(form.mobile),
website: optional(form.website),
taxNumber: optional(form.taxNumber),
commercialRegister: optional(form.commercialRegister),
address: optional(form.address),
city: optional(form.city),
country: form.country.trim() || 'Syria',
source: 'SUPPLIER_MODULE',
rating: form.rating || undefined,
tags: form.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
customFields: {
supplierCode: form.supplierCode.trim(),
supplierCategories,
supplierCategory: supplierCategories[0] || '',
paymentTerms: form.paymentTerms.trim(),
bankName: form.bankName.trim(),
bankAccount: form.bankAccount.trim(),
contactPosition: form.contactPosition.trim(),
notes: form.notes.trim(),
additionalContacts: cleanedAdditionalContacts,
},
}
if (isEdit) payload.status = form.status
await onSubmit(payload)
}
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900'
return (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">بيانات المورد الأساسية</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">اسم المورد / الشركة <span className="text-red-500">*</span></label><input value={form.companyName} onChange={(e) => updateField('companyName', e.target.value)} className={inputClass} placeholder="اسم الشركة أو المورد" />{formErrors.companyName && <p className="text-red-500 text-xs mt-1">{formErrors.companyName}</p>}</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">اسم المورد بالعربي</label><input value={form.companyNameAr} onChange={(e) => updateField('companyNameAr', e.target.value)} className={inputClass} dir="rtl" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">مسؤول التواصل الرئيسي</label><input value={form.name} onChange={(e) => updateField('name', e.target.value)} className={inputClass} placeholder="اسم الشخص المسؤول" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">الصفة / المنصب</label><input value={form.contactPosition} onChange={(e) => updateField('contactPosition', e.target.value)} className={inputClass} placeholder="Sales Manager, Accountant..." /></div>
</div>
</div>
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">التصنيف والاعتماد</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">كود المورد</label><input value={form.supplierCode} onChange={(e) => updateField('supplierCode', e.target.value)} className={`${inputClass} font-mono`} placeholder="SUP-001" /></div>
<div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">تصنيف المورد</label><SupplierCategorySelector selectedCategories={form.supplierCategories} availableCategories={availableCategories} onChange={(supplierCategories) => setForm((prev) => ({ ...prev, supplierCategories }))} /></div>
{isEdit && <div><label className="block text-sm font-medium text-gray-700 mb-1">الحالة</label><select value={form.status} onChange={(e) => updateField('status', e.target.value)} className={inputClass}><option value="ACTIVE">Active</option><option value="INACTIVE">Inactive</option><option value="BLOCKED">Blocked</option></select></div>}
</div>
<div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-2">التقييم</label><div className="flex items-center gap-2">{[1, 2, 3, 4, 5].map((star) => <button key={star} type="button" onClick={() => updateField('rating', star)} className="focus:outline-none"><Star className={`h-7 w-7 ${star <= form.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300 hover:text-yellow-200'}`} /></button>)}{form.rating > 0 && <button type="button" onClick={() => updateField('rating', 0)} className="text-sm text-gray-500 hover:text-gray-700">مسح التقييم</button>}</div></div>
</div>
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">بيانات التواصل</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div><label className="block text-sm font-medium text-gray-700 mb-1">البريد الإلكتروني</label><input type="email" value={form.email} onChange={(e) => updateField('email', e.target.value)} className={inputClass} />{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}</div><div><label className="block text-sm font-medium text-gray-700 mb-1">الهاتف</label><input value={form.phone} onChange={(e) => updateField('phone', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الموبايل</label><input value={form.mobile} onChange={(e) => updateField('mobile', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الموقع الإلكتروني</label><input value={form.website} onChange={(e) => updateField('website', e.target.value)} className={inputClass} /></div></div></div>
<div className="pt-6 border-t">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">أشخاص تواصل إضافيين</h3>
<button
type="button"
onClick={addAdditionalContact}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-lg hover:bg-emerald-100"
>
<Plus className="h-4 w-4" />
إضافة شخص تواصل
</button>
</div>
{form.additionalContacts.length === 0 ? (
<p className="text-sm text-gray-500">
لم تتم إضافة أشخاص تواصل إضافيين. اضغط "إضافة شخص تواصل" لإضافة المزيد.
</p>
) : (
<div className="space-y-4">
{form.additionalContacts.map((contact, index) => (
<div
key={index}
className="rounded-xl border border-gray-200 bg-gray-50/50 p-4 relative"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700">
شخص تواصل #{index + 1}
</h4>
<button
type="button"
onClick={() => removeAdditionalContact(index)}
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-700"
aria-label="حذف شخص التواصل"
>
<Trash2 className="h-4 w-4" />
حذف
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
الاسم <span className="text-red-500">*</span>
</label>
<input
value={contact.name}
onChange={(e) =>
updateAdditionalContact(index, 'name', e.target.value)
}
className={inputClass}
placeholder="اسم الشخص"
/>
{formErrors[`additionalContact_${index}_name`] && (
<p className="text-red-500 text-xs mt-1">
{formErrors[`additionalContact_${index}_name`]}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
الصفة / المنصب
</label>
<input
value={contact.position}
onChange={(e) =>
updateAdditionalContact(index, 'position', e.target.value)
}
className={inputClass}
placeholder="Sales Manager, Accountant..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
البريد الإلكتروني
</label>
<input
type="email"
value={contact.email}
onChange={(e) =>
updateAdditionalContact(index, 'email', e.target.value)
}
className={inputClass}
/>
{formErrors[`additionalContact_${index}_email`] && (
<p className="text-red-500 text-xs mt-1">
{formErrors[`additionalContact_${index}_email`]}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
الهاتف
</label>
<input
value={contact.phone}
onChange={(e) =>
updateAdditionalContact(index, 'phone', e.target.value)
}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
الموبايل
</label>
<input
value={contact.mobile}
onChange={(e) =>
updateAdditionalContact(index, 'mobile', e.target.value)
}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
ملاحظات
</label>
<input
value={contact.notes}
onChange={(e) =>
updateAdditionalContact(index, 'notes', e.target.value)
}
className={inputClass}
placeholder="ملاحظات إضافية..."
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">بيانات مالية وقانونية</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div><label className="block text-sm font-medium text-gray-700 mb-1">الرقم الضريبي</label><input value={form.taxNumber} onChange={(e) => updateField('taxNumber', e.target.value)} className={`${inputClass} font-mono`} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">السجل التجاري</label><input value={form.commercialRegister} onChange={(e) => updateField('commercialRegister', e.target.value)} className={`${inputClass} font-mono`} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">شروط الدفع</label><input value={form.paymentTerms} onChange={(e) => updateField('paymentTerms', e.target.value)} className={inputClass} placeholder="Net 30, Cash..." /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">اسم البنك</label><input value={form.bankName} onChange={(e) => updateField('bankName', e.target.value)} className={inputClass} /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">الحساب البنكي / IBAN</label><input value={form.bankAccount} onChange={(e) => updateField('bankAccount', e.target.value)} className={`${inputClass} font-mono`} /></div></div></div>
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">العنوان والملاحظات</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">العنوان</label><input value={form.address} onChange={(e) => updateField('address', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">المدينة</label><input value={form.city} onChange={(e) => updateField('city', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الدولة</label><input value={form.country} onChange={(e) => updateField('country', e.target.value)} className={inputClass} /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">Tags</label><input value={form.tags} onChange={(e) => updateField('tags', e.target.value)} className={inputClass} placeholder="tag1, tag2" /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">ملاحظات</label><textarea value={form.notes} onChange={(e) => updateField('notes', e.target.value)} className={inputClass} rows={3} /></div></div></div>
<div className="flex items-center justify-end gap-3 pt-6 border-t"><button type="button" onClick={onCancel} className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" disabled={submitting}>إلغاء</button><button type="submit" className="flex items-center gap-2 px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50" disabled={submitting}>{submitting ? <><Loader2 className="h-4 w-4 animate-spin" /> جاري الحفظ...</> : (isEdit ? 'تحديث المورد' : 'إنشاء المورد')}</button></div>
</form>
)
}
function SuppliersContent() {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [stats, setStats] = useState<SupplierStats>({ total: 0, active: 0, inactive: 0, blocked: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
const [searchTerm, setSearchTerm] = useState('')
const [selectedStatus, setSelectedStatus] = useState('all')
const [selectedCategory, setSelectedCategory] = useState('all')
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showArchiveDialog, setShowArchiveDialog] = useState(false)
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null)
const [submitting, setSubmitting] = useState(false)
const [exporting, setExporting] = useState(false)
const { hasPermission } = useAuth()
const canViewSupplier = hasPermission('suppliers', 'view')
const canCreateSupplier = hasPermission('suppliers', 'create')
const canEditSupplier = hasPermission('suppliers', 'edit')
const canDeleteSupplier = hasPermission('suppliers', 'delete')
const canExportSupplier = hasPermission('suppliers', 'export')
const availableCategories = useMemo(() => uniqueSupplierCategories([...DEFAULT_SUPPLIER_CATEGORIES, ...suppliers.flatMap(getSupplierCategoryLabels)]), [suppliers])
const buildFilters = useCallback((): SupplierFilters => {
const filters: SupplierFilters = { page: currentPage, pageSize }
if (searchTerm) filters.search = searchTerm
if (selectedStatus !== 'all') filters.status = selectedStatus
if (selectedCategory !== 'all') filters.category = selectedCategory
return filters
}, [currentPage, searchTerm, selectedStatus, selectedCategory])
const fetchStats = useCallback(async () => {
if (!canViewSupplier) return
try { setStats(await suppliersAPI.getStats()) } catch { setStats({ total: 0, active: 0, inactive: 0, blocked: 0 }) }
}, [canViewSupplier])
const fetchSuppliers = useCallback(async () => {
if (!canViewSupplier) {
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const data = await suppliersAPI.getAll(buildFilters())
setSuppliers(data.suppliers)
setTotal(data.total)
setTotalPages(data.totalPages)
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to load suppliers'
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}, [buildFilters, canViewSupplier])
useEffect(() => { fetchStats() }, [fetchStats])
useEffect(() => { const t = setTimeout(() => { setCurrentPage(1); fetchSuppliers() }, 500); return () => clearTimeout(t) }, [searchTerm])
useEffect(() => { fetchSuppliers() }, [currentPage, selectedStatus, selectedCategory])
const refresh = async () => Promise.all([fetchSuppliers(), fetchStats()])
const handleCreate = async (data: CreateSupplierData) => {
setSubmitting(true)
try { await suppliersAPI.create(data); toast.success('تم إنشاء المورد بنجاح'); setShowCreateModal(false); await refresh() }
catch (err: any) { toast.error(err.response?.data?.message || 'فشل إنشاء المورد'); throw err }
finally { setSubmitting(false) }
}
const handleEdit = async (data: UpdateSupplierData) => {
if (!selectedSupplier) return
setSubmitting(true)
try { await suppliersAPI.update(selectedSupplier.id, data); toast.success('تم تحديث المورد بنجاح'); setShowEditModal(false); setSelectedSupplier(null); await refresh() }
catch (err: any) { toast.error(err.response?.data?.message || 'فشل تحديث المورد'); throw err }
finally { setSubmitting(false) }
}
const handleArchive = async () => {
if (!selectedSupplier) return
setSubmitting(true)
try { await suppliersAPI.archive(selectedSupplier.id, 'Archived from Supplier Management'); toast.success('تم حذف المورد بنجاح'); setShowArchiveDialog(false); setSelectedSupplier(null); await refresh() }
catch (err: any) { toast.error(err.response?.data?.message || 'فشل حذف المورد') }
finally { setSubmitting(false) }
}
const handleExport = async () => {
setExporting(true)
try {
const filters = buildFilters(); delete filters.page; delete filters.pageSize
const blob = await suppliersAPI.export(filters)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `suppliers_${new Date().toISOString().split('T')[0]}.xlsx`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success('تم تصدير الموردين بنجاح')
} catch (err: any) { toast.error(err.response?.data?.message || 'فشل تصدير الموردين') }
finally { setExporting(false) }
}
if (!canViewSupplier) {
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 gap-4">
<Link href="/dashboard" 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>
<h1 className="text-2xl font-bold text-gray-900">إدارة الموردين</h1>
<p className="text-sm text-gray-600">Supplier Management</p>
</div>
</div>
</div>
</div>
</header>
</div>
)
}
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="/dashboard" 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><h1 className="text-2xl font-bold text-gray-900">إدارة الموردين</h1><p className="text-sm text-gray-600">Supplier Management</p></div></div>
</div>
<div className="flex items-center gap-3">
{canExportSupplier && (
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50"
disabled={exporting}
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export
</button>
)}
{canCreateSupplier && (
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
>
إضافة مورد
</button>
)}
</div>
</div>
</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 md:grid-cols-4 gap-6 mb-8"><StatCard label="Total Suppliers" value={stats.total} icon={Truck} iconClass="bg-emerald-100 text-emerald-600" /><StatCard label="Active Suppliers" value={stats.active} icon={BadgeCheck} iconClass="bg-green-100 text-green-600" /><StatCard label="Inactive / Blocked" value={stats.inactive + stats.blocked} icon={X} iconClass="bg-orange-100 text-orange-600" /><StatCard label="This Page" value={suppliers.length} icon={Landmark} iconClass="bg-purple-100 text-purple-600" /></div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6"><div className="space-y-4"><div className="flex flex-col md:flex-row gap-4"><div className="flex-1 relative"><Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" /><input type="text" placeholder="Search suppliers (name, phone, email, tax number...)" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900" /></div><select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)} className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900"><option value="all">All Status</option><option value="ACTIVE">Active</option><option value="INACTIVE">Inactive</option><option value="BLOCKED">Blocked</option></select><button onClick={() => setShowAdvancedFilters(!showAdvancedFilters)} className={`flex items-center gap-2 px-4 py-3 border rounded-lg ${showAdvancedFilters ? 'bg-emerald-50 border-emerald-300 text-emerald-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}><Filter className="h-4 w-4" /> Advanced</button></div>{showAdvancedFilters && <div className="pt-4 border-t border-gray-200 grid grid-cols-1 md:grid-cols-3 gap-4"><div><label className="block text-sm font-medium text-gray-700 mb-2">تصنيف المورد</label><select value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)} 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"><option value="all">كل التصنيفات</option>{availableCategories.map((category) => <option key={category} value={category}>{category}</option>)}</select></div><div className="flex items-end"><button onClick={() => { setSearchTerm(''); setSelectedStatus('all'); setSelectedCategory('all'); setCurrentPage(1) }} className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">Clear All Filters</button></div></div>}</div></div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? <div className="p-12"><LoadingSpinner size="lg" message="Loading suppliers..." /></div> : error ? <div className="p-12 text-center"><p className="text-red-600 mb-4">{error}</p><button onClick={fetchSuppliers} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">Retry</button></div> : suppliers.length === 0 ? <div className="p-12 text-center"><Truck className="h-12 w-12 text-gray-400 mx-auto mb-4" /><p className="text-gray-600 mb-4">لا يوجد موردين مطابقين للفلاتر الحالية</p><button onClick={() => setShowCreateModal(true)} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">إضافة مورد</button></div> : <><div className="overflow-x-auto"><table className="w-full"><thead className="bg-gray-50 border-b border-gray-200"><tr><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Supplier</th><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Category</th><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Rating</th><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th><th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th></tr></thead><tbody className="divide-y divide-gray-200">{suppliers.map((supplier) => { const customFields = supplier.customFields || {}; const categoryLabels = getSupplierCategoryLabels(supplier); return <tr key={supplier.id} className="hover:bg-gray-50"><td className="px-6 py-4"><div className="flex items-center gap-3"><div className="h-10 w-10 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-white font-bold">{getSupplierName(supplier).charAt(0).toUpperCase()}</div><div><p className="font-semibold text-gray-900">{getSupplierName(supplier)}</p>{customFields.supplierCode && <p className="text-xs text-gray-500 font-mono">{customFields.supplierCode}</p>}{supplier.companyNameAr && <p className="text-sm text-gray-600" dir="rtl">{supplier.companyNameAr}</p>}</div></div></td><td className="px-6 py-4"><div className="space-y-1">{getContactPerson(supplier) !== '-' && <div className="flex items-center gap-2 text-sm text-gray-700"><Building2 className="h-4 w-4" />{getContactPerson(supplier)}{customFields.contactPosition && <span className="text-xs text-gray-400">({customFields.contactPosition})</span>}</div>}{supplier.email && <div className="flex items-center gap-2 text-sm text-gray-600"><Mail className="h-4 w-4" />{supplier.email}</div>}{(supplier.phone || supplier.mobile) && <div className="flex items-center gap-2 text-sm text-gray-600"><Phone className="h-4 w-4" />{supplier.phone || supplier.mobile}</div>}</div></td><td className="px-6 py-4"><div className="flex flex-wrap gap-1">{categoryLabels.length > 0 ? categoryLabels.map((category) => <span key={category} 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" />{category}</span>) : <span className="text-sm text-gray-400">بدون تصنيف</span>}{customFields.paymentTerms && <div className="flex items-center gap-1 text-xs text-gray-500 w-full mt-1"><CircleDollarSign className="h-3 w-3" />{customFields.paymentTerms}</div>}</div></td><td className="px-6 py-4">{renderRating(supplier.rating)}</td><td className="px-6 py-4"><span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${supplier.status === 'ACTIVE' ? 'bg-green-100 text-green-700' : supplier.status === 'BLOCKED' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'}`}>{supplier.status === 'ACTIVE' ? 'Active' : supplier.status}</span></td><td className="px-6 py-4">
<div className="flex items-center gap-2">
{canViewSupplier && (
<Link
href={`/suppliers/${supplier.id}`}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg"
title="View"
>
<Eye className="h-4 w-4" />
</Link>
)}
{canEditSupplier && (
<button
onClick={() => { setSelectedSupplier(supplier); setShowEditModal(true) }}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
)}
{canDeleteSupplier && (
<button
onClick={() => { setSelectedSupplier(supplier); setShowArchiveDialog(true) }}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg"
title="Archive"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td></tr> })}</tbody></table></div><div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between"><p className="text-sm text-gray-600">Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to <span className="font-semibold">{Math.min(currentPage * pageSize, total)}</span> of <span className="font-semibold">{total}</span> suppliers</p><div className="flex items-center gap-2"><button onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50">Previous</button>{Array.from({ length: Math.min(5, totalPages) }, (_, i) => i + 1).map((page) => <button key={page} onClick={() => setCurrentPage(page)} className={`px-4 py-2 rounded-lg ${currentPage === page ? 'bg-emerald-600 text-white' : 'border border-gray-300 text-gray-700 hover:bg-gray-50'}`}>{page}</button>)}<button onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50">Next</button></div></div></>}
</div>
</main>
{canCreateSupplier && (
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مورد جديد" size="xl">
<SupplierForm
key="create-supplier"
availableCategories={availableCategories}
onSubmit={handleCreate}
onCancel={() => setShowCreateModal(false)}
submitting={submitting}
/>
</Modal>
)}
{canEditSupplier && (
<Modal isOpen={showEditModal} onClose={() => { setShowEditModal(false); setSelectedSupplier(null) }} title="تعديل المورد" size="xl">
<SupplierForm
key={selectedSupplier?.id || 'edit-supplier'}
supplier={selectedSupplier || undefined}
availableCategories={availableCategories}
onSubmit={handleEdit}
onCancel={() => { setShowEditModal(false); setSelectedSupplier(null) }}
submitting={submitting}
/>
</Modal>
)}
{canDeleteSupplier && showArchiveDialog && selectedSupplier && <div className="fixed inset-0 z-50 overflow-y-auto"><div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowArchiveDialog(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"><div className="flex items-center gap-4 mb-4"><div className="bg-red-100 p-3 rounded-full"><Trash2 className="h-6 w-6 text-red-600" /></div><div><h3 className="text-lg font-bold text-gray-900">حذف المورد</h3><p className="text-sm text-gray-600">سيتم إخفاء المورد من قائمة الموردين</p></div></div><p className="text-gray-700 mb-6">هل أنت متأكد من حذف <span className="font-semibold">{getSupplierName(selectedSupplier)}</span>؟</p><div className="flex items-center justify-end gap-3"><button onClick={() => { setShowArchiveDialog(false); setSelectedSupplier(null) }} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" disabled={submitting}>إلغاء</button><button onClick={handleArchive} className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50" disabled={submitting}>{submitting ? <><Loader2 className="h-4 w-4 animate-spin" /> جاري الحذف...</> : 'حذف المورد'}</button></div></div></div></div>}
</div>
)
}
function StatCard({ label, value, icon: Icon, iconClass }: { label: string; value: number; icon: any; iconClass: string }) {
return <div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200"><div className="flex items-center justify-between"><div><p className="text-sm text-gray-600">{label}</p><p className="text-3xl font-bold text-gray-900 mt-1">{value}</p></div><div className={`p-3 rounded-lg ${iconClass}`}><Icon className="h-8 w-8" /></div></div></div>
}
export default function SuppliersPage() {
return <ProtectedRoute><SuppliersContent /></ProtectedRoute>
}

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useParams} from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { import {
@@ -24,7 +25,6 @@ import Modal from '@/components/Modal'
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders' import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
import { contactsAPI } from '@/lib/api/contacts' import { contactsAPI } from '@/lib/api/contacts'
import { pipelinesAPI } from '@/lib/api/pipelines' import { pipelinesAPI } from '@/lib/api/pipelines'
import { employeesAPI } from '@/lib/api/employees'
import { useLanguage } from '@/contexts/LanguageContext' import { useLanguage } from '@/contexts/LanguageContext'
const DIRECTIVE_TYPE_LABELS: Record<string, string> = { const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
@@ -34,7 +34,26 @@ const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
PREPARE_TO_BID: 'Prepare to bid', PREPARE_TO_BID: 'Prepare to bid',
} }
const getDisplayFileName = (attachment: any) => {
const name = String(attachment.originalName || attachment.fileName || 'file')
if (!/[ÃÄÅØÙ]/.test(name)) {
return name
}
try {
const bytes = new Uint8Array(
Array.from(name, (char: string) => char.charCodeAt(0) & 0xff)
)
return new TextDecoder('utf-8').decode(bytes)
} catch {
return name
}
}
function TenderDetailContent() { function TenderDetailContent() {
const searchParams = useSearchParams()
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const tenderId = params.id as string const tenderId = params.id as string
@@ -43,7 +62,12 @@ function TenderDetailContent() {
const [tender, setTender] = useState<Tender | null>(null) const [tender, setTender] = useState<Tender | null>(null)
const [history, setHistory] = useState<any[]>([]) const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'info' | 'directives' | 'attachments' | 'history'>('info') type TenderTab = 'info' | 'directives' | 'attachments' | 'history'
const [activeTab, setActiveTab] = useState<TenderTab>('info')
const openTab = (tab: TenderTab) => {
setActiveTab(tab)
router.replace(`/tenders/${params.id}?tab=${tab}`)
}
const [showDirectiveModal, setShowDirectiveModal] = useState(false) const [showDirectiveModal, setShowDirectiveModal] = useState(false)
const [showConvertModal, setShowConvertModal] = useState(false) const [showConvertModal, setShowConvertModal] = useState(false)
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null) const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
@@ -63,10 +87,13 @@ function TenderDetailContent() {
const [completeNotes, setCompleteNotes] = useState('') const [completeNotes, setCompleteNotes] = useState('')
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([]) const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const directiveFileInputRef = useRef<HTMLInputElement>(null) const directiveFileInputRef = useRef<HTMLInputElement>(null)
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null) const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null) const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
const termsInputRef = useRef<HTMLInputElement>(null)
const costInputRef = useRef<HTMLInputElement>(null)
const offersInputRef = useRef<HTMLInputElement>(null)
const fetchTender = async () => { const fetchTender = async () => {
try { try {
@@ -85,6 +112,15 @@ function TenderDetailContent() {
setHistory(data) setHistory(data)
} catch {} } catch {}
} }
useEffect(() => {
const tabParam = searchParams.get('tab') as TenderTab | null
const allowedTabs: TenderTab[] = ['info', 'directives', 'attachments', 'history']
if (tabParam && allowedTabs.includes(tabParam)) {
setActiveTab(tabParam)
}
}, [searchParams])
useEffect(() => { useEffect(() => {
fetchTender() fetchTender()
@@ -100,9 +136,12 @@ function TenderDetailContent() {
useEffect(() => { useEffect(() => {
if (showDirectiveModal || showConvertModal) { if (showDirectiveModal || showConvertModal) {
employeesAPI // Use the directive-scoped employee list so non-HR users (with
.getAll({ status: 'ACTIVE', pageSize: 500 }) // tenders:directives:create) can populate this dropdown without
.then((r: any) => setEmployees(r.employees || [])) // being granted hr:employees:read (which would leak salaries etc.).
tendersAPI
.getAssignableEmployees()
.then((list) => setEmployees(list))
.catch(() => {}) .catch(() => {})
} }
@@ -177,19 +216,42 @@ function TenderDetailContent() {
} }
} }
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleTenderFileUpload = async (
const file = e.target.files?.[0] e: React.ChangeEvent<HTMLInputElement>,
if (!file) return category?: string,
) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
if (category) setUploadingCategory(category)
else setSubmitting(true)
let successCount = 0
let failCount = 0
setSubmitting(true)
try { try {
await tendersAPI.uploadTenderAttachment(tenderId, file) // Upload files sequentially so a failure of one file doesn't break the rest.
toast.success(t('tenders.uploadFile')) for (const file of files) {
fetchTender() try {
} catch (err: any) { await tendersAPI.uploadTenderAttachment(tenderId, file, category)
toast.error(err.response?.data?.message || 'Upload failed') successCount++
} catch (err: any) {
failCount++
const msg = err.response?.data?.message || 'Upload failed'
toast.error(`${file.name}: ${msg}`)
}
}
if (successCount > 0) {
toast.success(
files.length === 1
? t('tenders.uploadFile')
: `${successCount}/${files.length}`
)
}
if (successCount > 0) fetchTender()
} finally { } finally {
setSubmitting(false) setSubmitting(false)
setUploadingCategory(null)
e.target.value = '' e.target.value = ''
} }
} }
@@ -200,20 +262,35 @@ function TenderDetailContent() {
} }
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const files = Array.from(e.target.files || [])
const directiveId = directiveIdForUpload const directiveId = directiveIdForUpload
e.target.value = '' e.target.value = ''
setDirectiveIdForUpload(null) setDirectiveIdForUpload(null)
if (!file || !directiveId) return if (!files.length || !directiveId) return
setUploadingDirectiveId(directiveId) setUploadingDirectiveId(directiveId)
let successCount = 0
try { try {
await tendersAPI.uploadDirectiveAttachment(directiveId, file) for (const file of files) {
toast.success(t('tenders.uploadFile')) try {
fetchTender() await tendersAPI.uploadDirectiveAttachment(directiveId, file)
} catch (err: any) { successCount++
toast.error(err.response?.data?.message || 'Upload failed') } catch (err: any) {
const msg = err.response?.data?.message || 'Upload failed'
toast.error(`${file.name}: ${msg}`)
}
}
if (successCount > 0) {
toast.success(
files.length === 1
? t('tenders.uploadFile')
: `${successCount}/${files.length}`
)
fetchTender()
}
} finally { } finally {
setUploadingDirectiveId(null) setUploadingDirectiveId(null)
} }
@@ -266,7 +343,7 @@ function TenderDetailContent() {
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id as any)} onClick={() => openTab(tab.id as TenderTab)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
activeTab === tab.id activeTab === tab.id
? 'bg-indigo-100 text-indigo-800' ? 'bg-indigo-100 text-indigo-800'
@@ -428,6 +505,7 @@ function TenderDetailContent() {
<input <input
type="file" type="file"
ref={directiveFileInputRef} ref={directiveFileInputRef}
multiple
className="hidden" className="hidden"
onChange={handleDirectiveFileUpload} onChange={handleDirectiveFileUpload}
/> />
@@ -455,65 +533,102 @@ function TenderDetailContent() {
{activeTab === 'attachments' && ( {activeTab === 'attachments' && (
<div> <div>
<div className="flex items-center gap-4 mb-4"> {(() => {
<input const all = (tender.attachments || []) as any[]
type="file" const sections: Array<{
ref={fileInputRef} key: string
className="hidden" label: string
onChange={handleTenderFileUpload} category: string
/> ref: React.RefObject<HTMLInputElement>
<button }> = [
onClick={() => fileInputRef.current?.click()} { key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
disabled={submitting} { key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50" { key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
> ]
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
{!tender.attachments?.length ? ( // Legacy attachments without a recognized category live under
<p className="text-gray-500">{t('common.noData')}</p> // the dafter section by default so nothing gets hidden.
) : ( const knownCategories = new Set(sections.map((s) => s.category))
<ul className="space-y-2"> const inSection = (a: any, category: string) =>
{tender.attachments.map((a: any) => ( a.category === category ||
<li (category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
key={a.id}
className="flex items-center justify-between border rounded px-3 py-2"
>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
{a.originalName || a.fileName}
</a>
<button return (
onClick={async () => { <div className="space-y-6">
if (!confirm('حذف الملف؟')) return {sections.map((section) => {
try { const items = all.filter((a) => inSection(a, section.category))
await tendersAPI.deleteAttachment(a.id) const isUploading = uploadingCategory === section.category
toast.success('تم الحذف') return (
fetchTender() <div key={section.key} className="border rounded-lg p-4">
} catch { <div className="flex items-center justify-between mb-3">
toast.error('فشل الحذف') <h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
} <div>
}} <input
className="text-red-600 text-sm hover:underline" type="file"
> ref={section.ref}
حذف multiple
</button> className="hidden"
</li> onChange={(e) => handleTenderFileUpload(e, section.category)}
))} />
</ul> <button
)} type="button"
onClick={() => section.ref.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
</div>
{items.length === 0 ? (
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{items.map((a: any) => (
<li
key={a.id}
className="flex items-center justify-between border rounded px-3 py-2"
>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
{getDisplayFileName(a)}
</a>
<button
onClick={async () => {
if (!confirm('حذف الملف؟')) return
try {
await tendersAPI.deleteAttachment(a.id)
toast.success('تم الحذف')
fetchTender()
} catch {
toast.error('فشل الحذف')
}
}}
className="text-red-600 text-sm hover:underline"
>
حذف
</button>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
)
})()}
</div> </div>
)} )}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -91,6 +91,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
'UN', 'UN',
'NGO', 'NGO',
'INSTITUTION', 'INSTITUTION',
'SUPPLIER',
]) ])
const isOrganizationType = organizationTypes.has(formData.type) const isOrganizationType = organizationTypes.has(formData.type)
@@ -126,7 +127,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
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
@@ -223,6 +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>
</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>

View File

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

View File

@@ -29,6 +29,7 @@ interface Permission {
canDelete?: boolean canDelete?: boolean
canExport?: boolean canExport?: boolean
canApprove?: boolean canApprove?: boolean
canMarkAsPaid?: boolean
} }
interface AuthContextType { interface AuthContextType {
@@ -37,7 +38,7 @@ interface AuthContextType {
logout: () => void logout: () => void
isLoading: boolean isLoading: boolean
isAuthenticated: boolean isAuthenticated: boolean
hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve') => boolean hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid') => boolean
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextType | undefined>(undefined)
@@ -77,6 +78,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
canDelete: wildcard || p.actions?.includes('delete') || false, canDelete: wildcard || p.actions?.includes('delete') || false,
canExport: wildcard || p.actions?.includes('export') || false, canExport: wildcard || p.actions?.includes('export') || false,
canApprove: wildcard || p.actions?.includes('approve') || false, canApprove: wildcard || p.actions?.includes('approve') || false,
canMarkAsPaid: wildcard || p.actions?.includes('mark-as-paid') || false,
} }
}) })
} }
@@ -148,7 +150,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.push('/') router.push('/')
} }
const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve'): boolean => { const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid'): boolean => {
if (!user?.role?.permissions) return false if (!user?.role?.permissions) return false
const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase()) const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase())
@@ -160,7 +162,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
edit: 'canEdit', edit: 'canEdit',
delete: 'canDelete', delete: 'canDelete',
export: 'canExport', export: 'canExport',
approve: 'canApprove' approve: 'canApprove',
'mark-as-paid': 'canMarkAsPaid'
} }
return permission[actionMap[action] as keyof Permission] as boolean return permission[actionMap[action] as keyof Permission] as boolean

View File

@@ -551,7 +551,7 @@ const translations = {
view: 'عرض', view: 'عرض',
win: 'فوز', win: 'فوز',
lose: 'خسارة', lose: 'خسارة',
archive: 'أرشفة', delete: 'حذف',
deleteDeal: 'حذف الصفقة', deleteDeal: 'حذف الصفقة',
markWon: 'تحديد كفائز', markWon: 'تحديد كفائز',
markLost: 'تحديد كخاسر', markLost: 'تحديد كخاسر',
@@ -563,7 +563,7 @@ const translations = {
updateSuccess: 'تم تحديث الصفقة بنجاح', updateSuccess: 'تم تحديث الصفقة بنجاح',
winSuccess: 'تم الفوز بالصفقة بنجاح', winSuccess: 'تم الفوز بالصفقة بنجاح',
loseSuccess: 'تم تحديد الصفقة كخاسرة', loseSuccess: 'تم تحديد الصفقة كخاسرة',
deleteSuccess: 'تم أرشفة الصفقة بنجاح', deleteSuccess: 'تم حذف الصفقة بنجاح',
fixFormErrors: 'يرجى إصلاح أخطاء النموذج', fixFormErrors: 'يرجى إصلاح أخطاء النموذج',
pipelineRequired: 'مسار المبيعات مطلوب', pipelineRequired: 'مسار المبيعات مطلوب',
dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل', dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',

View File

@@ -81,6 +81,21 @@ export const dashboardAPI = {
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'), getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
} }
export const notificationsAPI = {
getMy: (params?: { page?: number; pageSize?: number }) =>
api.get('/notifications/my', { params }),
getUnreadCount: () =>
api.get('/notifications/unread-count'),
markAsRead: (id: string) =>
api.patch(`/notifications/${id}/read`),
markAllAsRead: () =>
api.patch('/notifications/read-all'),
}
export const crmAPI = { export const crmAPI = {
// Deals // Deals
getDeals: (params?: any) => api.get('/crm/deals', { params }), getDeals: (params?: any) => api.get('/crm/deals', { params }),

View File

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

View File

@@ -16,6 +16,7 @@ export interface PortalProfile {
activeLoansCount: number activeLoansCount: number
pendingLeavesCount: number pendingLeavesCount: number
pendingPurchaseRequestsCount: number pendingPurchaseRequestsCount: number
pendingExpenseClaimsCount: number
leaveBalance: Array<{ leaveBalance: Array<{
leaveType: string leaveType: string
totalDays: number totalDays: number
@@ -98,6 +99,53 @@ export interface PurchaseRequest {
createdAt: string createdAt: string
} }
export interface ExpenseClaim {
id: string;
employeeId: string;
claimNumber: string;
items?: ExpenseClaimItem[] | null;
totalAmount?: number | null;
expenseDate: string | null;
amount: number | null;
description: string | null;
projectOrTender: string | null;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | string;
approvedBy?: string | null;
approvedAt?: string | null;
rejectedReason?: string | null;
approvalNote?: string | null;
isPaid?: boolean;
createdAt: string;
updatedAt: string;
attachments?: Array<{
id: string;
fileName: string;
originalName: string;
mimeType: string;
size: number;
uploadedAt: string;
}> | null;
employee?: {
id: string;
firstName: string;
lastName: string;
uniqueEmployeeId?: string | null;
reportingToId?: string | null;
};
}
export interface ExpenseClaimItem {
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}
export interface Attendance { export interface Attendance {
id: string id: string
date: string date: string
@@ -143,6 +191,17 @@ export interface Salary {
export const portalAPI = { export const portalAPI = {
viewExpenseClaimAttachment: async (attachmentId: string): Promise<Blob> => {
const response = await api.get(
`/hr/portal/expense-claims/attachments/${attachmentId}/view`,
{
responseType: 'blob',
}
);
return response.data;
},
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => { getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
const q = new URLSearchParams() const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status) if (status && status !== 'all') q.append('status', status)
@@ -175,6 +234,18 @@ export const portalAPI = {
return response.data.data return response.data.data
}, },
updateLoanRequest: async (
id: string,
data: { type?: string; amount?: number; installments?: number; reason?: string }
): Promise<Loan> => {
const response = await api.put(`/hr/portal/loans/${id}`, data)
return response.data.data
},
deleteLoanRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/loans/${id}`)
},
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => { getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/overtime-requests') const response = await api.get('/hr/portal/overtime-requests')
return response.data.data || [] return response.data.data || []
@@ -189,6 +260,18 @@ export const portalAPI = {
return response.data.data return response.data.data
}, },
updateOvertimeRequest: async (
attendanceId: string,
data: { hours?: number; reason?: string }
): Promise<PortalOvertimeRequest> => {
const response = await api.put(`/hr/portal/overtime-requests/${attendanceId}`, data)
return response.data.data
},
deleteOvertimeRequest: async (attendanceId: string): Promise<void> => {
await api.delete(`/hr/portal/overtime-requests/${attendanceId}`)
},
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => { getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/managed-overtime-requests') const response = await api.get('/hr/portal/managed-overtime-requests')
return response.data.data || [] return response.data.data || []
@@ -220,11 +303,39 @@ export const portalAPI = {
return response.data.data || [] return response.data.data || []
}, },
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => { submitLeaveRequest: async (data: {
const response = await api.post('/hr/portal/leaves', data) leaveType: string
startDate: string
endDate: string
leaveDate?: string
startTime?: string
endTime?: string
reason?: string
}): Promise<Leave> => {
const response = await api.post('/hr/portal/leaves', data)
return response.data.data return response.data.data
}, },
updateLeaveRequest: async (
id: string,
data: {
leaveType?: string
startDate?: string
endDate?: string
leaveDate?: string
startTime?: string
endTime?: string
reason?: string
}
): Promise<Leave> => {
const response = await api.put(`/hr/portal/leaves/${id}`, data)
return response.data.data
},
deleteLeaveRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/leaves/${id}`)
},
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => { getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
const response = await api.get('/hr/portal/purchase-requests') const response = await api.get('/hr/portal/purchase-requests')
return response.data.data || [] return response.data.data || []
@@ -235,6 +346,126 @@ export const portalAPI = {
return response.data.data return response.data.data
}, },
updatePurchaseRequest: async (
id: string,
data: {
items?: Array<{ description: string; quantity?: number; estimatedPrice?: number }>
reason?: string
priority?: string
}
): Promise<PurchaseRequest> => {
const response = await api.put(`/hr/portal/purchase-requests/${id}`, data)
return response.data.data
},
deletePurchaseRequest: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/purchase-requests/${id}`)
},
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
const response = await api.get('/hr/portal/expense-claims')
return response.data.data || []
},
submitExpenseClaim: async (data: {
items: Array<{
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
attachments?: File[];
}): Promise<ExpenseClaim> => {
const formData = new FormData();
formData.append('items', JSON.stringify(data.items));
if (data.description) {
formData.append('description', data.description);
}
if (data.attachments && data.attachments.length > 0) {
for (const file of data.attachments) {
formData.append('attachments', file);
}
}
const response = await api.post('/hr/portal/expense-claims', formData, {
headers: { 'Content-Type': undefined as any },
});
return response.data.data;
},
updateExpenseClaim: async (
id: string,
data: {
items: Array<{
expenseDate: string;
amount: number;
entityName?: string;
description: string;
projectOrTender?: string;
proofRef?: string;
}>;
description?: string;
attachments?: File[];
removeAttachmentIds?: string[];
}
): Promise<ExpenseClaim> => {
const formData = new FormData();
formData.append('items', JSON.stringify(data.items));
if (data.description) formData.append('description', data.description);
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
formData.append('removeAttachmentIds', JSON.stringify(data.removeAttachmentIds));
}
if (data.attachments && data.attachments.length > 0) {
for (const file of data.attachments) formData.append('attachments', file);
}
const response = await api.put(`/hr/portal/expense-claims/${id}`, formData, {
headers: { 'Content-Type': undefined as any },
});
return response.data.data;
},
deleteExpenseClaim: async (id: string): Promise<void> => {
await api.delete(`/hr/portal/expense-claims/${id}`);
},
getManagedExpenseClaims: async (
status?: string,
search?: string,
paid?: 'all' | 'paid' | 'unpaid',
): Promise<ExpenseClaim[]> => {
const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status)
if (search && search.trim()) q.append('search', search.trim())
if (paid && paid !== 'all') q.append('paid', paid)
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
return response.data.data || []
},
approveManagedExpenseClaim: async (id: string, approvalNote?: string): Promise<ExpenseClaim> => {
const response = await api.post(
`/hr/portal/managed-expense-claims/${id}/approve`,
approvalNote?.trim() ? { approvalNote: approvalNote.trim() } : {},
);
return response.data.data;
},
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/reject`, { rejectedReason })
return response.data.data
},
markExpenseClaimPaid: async (id: string, isPaid: boolean): Promise<ExpenseClaim> => {
const response = await api.patch(`/hr/portal/managed-expense-claims/${id}/paid`, { isPaid })
return response.data.data
},
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => { getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (month) params.append('month', String(month)) if (month) params.append('month', String(month))

View File

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

View File

@@ -115,17 +115,49 @@ export interface Project {
name: string name: string
nameAr?: string nameAr?: string
description?: string description?: string
type?: string
status: string status: string
priority?: string
progress?: number
startDate?: string startDate?: string
endDate?: string endDate?: string
budget?: number budget?: number
estimatedCost?: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export interface CreateProjectData {
name: string
description?: string
type: string
startDate: string
endDate?: string
estimatedCost?: number
status?: string
priority?: string
progress?: number
}
export interface UpdateProjectData extends Partial<CreateProjectData> {}
export const projectsAPI = { export const projectsAPI = {
getAll: async (): Promise<Project[]> => { getAll: async (): Promise<Project[]> => {
const response = await api.get('/projects/projects') const response = await api.get('/projects/projects')
return response.data.data return response.data.data
} },
create: async (data: CreateProjectData): Promise<Project> => {
const response = await api.post('/projects/projects', data)
return response.data.data
},
update: async (id: string, data: UpdateProjectData): Promise<Project> => {
const response = await api.put(`/projects/projects/${id}`, data)
return response.data.data
},
delete: async (id: string): Promise<void> => {
await api.delete(`/projects/projects/${id}`)
},
} }

View File

@@ -3,6 +3,7 @@ import { api } from '../api'
export interface Tender { export interface Tender {
id: string id: string
tenderNumber: string tenderNumber: string
issueNumber?: string | null
issuingBodyName: string issuingBodyName: string
title: string title: string
@@ -57,6 +58,7 @@ export interface TenderDirective {
export interface CreateTenderData { export interface CreateTenderData {
tenderNumber: string tenderNumber: string
issueNumber?: string
issuingBodyName: string issuingBodyName: string
title: string title: string
@@ -167,12 +169,16 @@ export const tendersAPI = {
return response.data.data return response.data.data
}, },
delete: async (id: string): Promise<void> => {
await api.delete(`/tenders/${id}`)
},
uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => { uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
if (category) formData.append('category', category) if (category) formData.append('category', category)
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, { const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': undefined as any },
}) })
return response.data.data return response.data.data
}, },
@@ -182,7 +188,7 @@ export const tendersAPI = {
formData.append('file', file) formData.append('file', file)
if (category) formData.append('category', category) if (category) formData.append('category', category)
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, { const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': undefined as any },
}) })
return response.data.data return response.data.data
}, },
@@ -205,4 +211,17 @@ export const tendersAPI = {
const response = await api.get('/tenders/directive-type-values') const response = await api.get('/tenders/directive-type-values')
return response.data.data return response.data.data
}, },
}
// Minimal employee list (id + names only) safe to call without
// hr:employees:read. Used only to populate the directive assignee dropdown.
getAssignableEmployees: async (): Promise<Array<{
id: string
firstName: string
lastName: string
firstNameAr: string | null
lastNameAr: string | null
}>> => {
const response = await api.get('/tenders/assignable-employees')
return response.data.data
},
}

View File

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