Compare commits
16 Commits
93f6e23861
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 96386887fb | |||
| 61ca570e7a | |||
| 12c4ca8334 | |||
| 7732a40726 | |||
| 31c59a3c9f | |||
| e01e351713 | |||
| 9e5dd47a2f | |||
| da4cb36036 | |||
| 8621096a82 | |||
| 287401f1da | |||
| 345ba195f8 | |||
| 11d14c01d2 | |||
| 0a9e1bbd4d | |||
| e262d8c09c | |||
| 417a5ac661 | |||
| 18699e6926 |
@@ -56,8 +56,8 @@ RUN npm ci --only=production && \
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Change ownership of all files to the nodejs user
|
||||
RUN chown -R expressjs:nodejs /app
|
||||
# Ensure uploads directory exists and is owned by app user
|
||||
RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER expressjs
|
||||
|
||||
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal file
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -200,6 +200,7 @@ model Employee {
|
||||
commissions Commission[]
|
||||
loans Loan[]
|
||||
purchaseRequests PurchaseRequest[]
|
||||
expenseClaims ExpenseClaim[]
|
||||
leaveEntitlements LeaveEntitlement[]
|
||||
employeeContracts EmployeeContract[]
|
||||
tenderDirectivesAssigned TenderDirective[]
|
||||
@@ -301,8 +302,8 @@ model Leave {
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc.
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime @db.Date
|
||||
startDate DateTime @db.Timestamp(3)
|
||||
endDate DateTime @db.Timestamp(3)
|
||||
days Int
|
||||
reason String?
|
||||
status String @default("PENDING") // PENDING, APPROVED, REJECTED
|
||||
@@ -503,6 +504,37 @@ model PurchaseRequest {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
@@ -889,6 +921,7 @@ model Invoice {
|
||||
model Tender {
|
||||
id String @id @default(uuid())
|
||||
tenderNumber String @unique
|
||||
issueNumber String?
|
||||
issuingBodyName String
|
||||
title String
|
||||
termsValue Decimal @db.Decimal(15, 2)
|
||||
@@ -911,6 +944,7 @@ model Tender {
|
||||
attachments Attachment[]
|
||||
convertedDeal Deal?
|
||||
@@index([tenderNumber])
|
||||
@@index([issueNumber])
|
||||
@@index([status])
|
||||
@@index([createdById])
|
||||
@@index([announcementDate])
|
||||
@@ -1594,3 +1628,12 @@ model Approval {
|
||||
@@map("approvals")
|
||||
}
|
||||
|
||||
model SystemSetting {
|
||||
key String @id
|
||||
value String
|
||||
category String @default("general")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("system_settings")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -33,10 +34,10 @@ export const config = {
|
||||
},
|
||||
|
||||
upload: {
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
||||
path: process.env.UPLOAD_PATH || './uploads',
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10),
|
||||
path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'),
|
||||
},
|
||||
|
||||
|
||||
pagination: {
|
||||
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
||||
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
||||
|
||||
@@ -35,6 +35,7 @@ class ContactsController {
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined,
|
||||
createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined,
|
||||
excludeSuppliers: req.query.excludeSuppliers === 'true',
|
||||
};
|
||||
|
||||
const result = await contactsService.findAll(filters, page, pageSize);
|
||||
@@ -241,6 +242,7 @@ class ContactsController {
|
||||
category: req.query.category as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
|
||||
excludeSuppliers: req.query.excludeSuppliers === 'true',
|
||||
};
|
||||
|
||||
const buffer = await contactsService.export(filters);
|
||||
|
||||
@@ -43,7 +43,7 @@ router.post(
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
[
|
||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
||||
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
||||
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION','SUPPLIER',]),
|
||||
body('name').notEmpty().trim(),
|
||||
body('email').optional().isEmail(),
|
||||
body('source').notEmpty(),
|
||||
@@ -73,6 +73,7 @@ router.put(
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
'SUPPLIER',
|
||||
]),
|
||||
body('email')
|
||||
.optional({ values: 'falsy' })
|
||||
|
||||
@@ -43,6 +43,7 @@ interface SearchFilters {
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
excludeCompanyEmployees?: boolean;
|
||||
excludeSuppliers?: boolean;
|
||||
}
|
||||
|
||||
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) {
|
||||
where.createdAt = {};
|
||||
if (filters.createdFrom) {
|
||||
@@ -758,6 +775,7 @@ class ContactsService {
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
status: { not: 'DELETED' },
|
||||
};
|
||||
const notConditions: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -772,19 +790,39 @@ class ContactsService {
|
||||
if (filters.source) where.source = filters.source;
|
||||
if (filters.rating) where.rating = filters.rating;
|
||||
|
||||
if (filters.excludeSuppliers) {
|
||||
notConditions.push(
|
||||
{ type: 'SUPPLIER' },
|
||||
{
|
||||
categories: {
|
||||
some: {
|
||||
OR: [
|
||||
{ name: { in: ['Supplier', 'Suppliers'] } },
|
||||
{ nameAr: { contains: 'مورد' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.excludeCompanyEmployees) {
|
||||
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
|
||||
where: { name: 'Company Employee', isActive: true },
|
||||
});
|
||||
if (companyEmployeeCategory) {
|
||||
where.NOT = {
|
||||
notConditions.push({
|
||||
categories: {
|
||||
some: { id: companyEmployeeCategory.id },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (notConditions.length > 0) {
|
||||
where.NOT = notConditions;
|
||||
}
|
||||
|
||||
// Fetch all contacts (no pagination for export)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
|
||||
@@ -2,8 +2,65 @@ import { Router } from 'express';
|
||||
import { hrController } from './hr.controller';
|
||||
import { portalController } from './portal.controller';
|
||||
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 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);
|
||||
|
||||
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
||||
@@ -11,9 +68,13 @@ router.use(authenticate);
|
||||
router.get('/portal/me', portalController.getMe);
|
||||
router.get('/portal/loans', portalController.getMyLoans);
|
||||
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/leaves', portalController.getMyLeaves);
|
||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||
router.put('/portal/leaves/:id', portalController.updateMyLeave);
|
||||
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-leaves',
|
||||
@@ -35,6 +96,8 @@ router.post(
|
||||
|
||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||
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(
|
||||
'/portal/managed-overtime-requests',
|
||||
@@ -56,9 +119,69 @@ router.post(
|
||||
|
||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||
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/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 ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
|
||||
@@ -313,54 +313,77 @@ class HRService {
|
||||
// ========== LEAVES ==========
|
||||
|
||||
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())) {
|
||||
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}`);
|
||||
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||
}
|
||||
|
||||
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) {
|
||||
const leave = await prisma.leave.update({
|
||||
where: { id },
|
||||
|
||||
@@ -144,19 +144,65 @@ export class PortalController {
|
||||
}
|
||||
}
|
||||
|
||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
startDate: new Date(req.body.startDate),
|
||||
endDate: new Date(req.body.endDate),
|
||||
};
|
||||
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 submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = { ...req.body };
|
||||
const leaveType = String(body.leaveType || '').toUpperCase();
|
||||
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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) {
|
||||
try {
|
||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||
@@ -195,6 +381,178 @@ export class PortalController {
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
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 {
|
||||
private requireEmployeeId(employeeId: string | undefined): string {
|
||||
@@ -31,10 +60,11 @@ class PortalService {
|
||||
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.leave.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()),
|
||||
]);
|
||||
|
||||
@@ -44,6 +74,7 @@ class PortalService {
|
||||
activeLoansCount: loansCount,
|
||||
pendingLeavesCount: pendingLeaves,
|
||||
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
||||
pendingExpenseClaimsCount: pendingExpenseClaims,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -445,6 +567,406 @@ class PortalService {
|
||||
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) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const now = new Date();
|
||||
@@ -461,6 +983,376 @@ class PortalService {
|
||||
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();
|
||||
@@ -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();
|
||||
14
backend/src/modules/notifications/notifications.routes.ts
Normal file
14
backend/src/modules/notifications/notifications.routes.ts
Normal 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;
|
||||
404
backend/src/modules/notifications/notifications.service.ts
Normal file
404
backend/src/modules/notifications/notifications.service.ts
Normal 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();
|
||||
@@ -3,11 +3,169 @@ import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import prisma from '../../config/database';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
@@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||
}
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (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) => {
|
||||
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 project = await prisma.project.create({
|
||||
data: { ...req.body, projectNumber },
|
||||
data: { ...data, projectNumber },
|
||||
});
|
||||
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'PROJECT',
|
||||
entityId: project.id,
|
||||
action: 'CREATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(project));
|
||||
} catch (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) => {
|
||||
try {
|
||||
const data = sanitizeProjectBody(req.body, { isUpdate: true });
|
||||
const project = await prisma.project.update({
|
||||
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));
|
||||
} catch (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
|
||||
// ============================================================
|
||||
|
||||
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||
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 = {};
|
||||
if (req.query.projectId) where.projectId = req.query.projectId;
|
||||
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
|
||||
if (req.query.status) where.status = req.query.status;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
if (req.query.priority) where.priority = req.query.priority;
|
||||
|
||||
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: {
|
||||
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) {
|
||||
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) => {
|
||||
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 task = await prisma.task.create({
|
||||
data: { ...req.body, taskNumber },
|
||||
data: { ...data, taskNumber },
|
||||
include: { project: true, assignedTo: true },
|
||||
});
|
||||
|
||||
|
||||
// Create notification for assigned user
|
||||
if (task.assignedToId) {
|
||||
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));
|
||||
} catch (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) => {
|
||||
try {
|
||||
const data = sanitizeTaskBody(req.body, { isUpdate: true });
|
||||
const task = await prisma.task.update({
|
||||
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));
|
||||
} catch (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;
|
||||
|
||||
89
backend/src/modules/suppliers/suppliers.controller.ts
Normal file
89
backend/src/modules/suppliers/suppliers.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
import { suppliersService } from './suppliers.service';
|
||||
|
||||
class SuppliersController {
|
||||
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
status: req.query.status as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
category: req.query.category as string,
|
||||
};
|
||||
|
||||
const result = await suppliersService.findAll(filters, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.suppliers, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await suppliersService.getStats();
|
||||
res.json(ResponseFormatter.success(stats));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supplier = await suppliersService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(supplier));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supplier = await suppliersService.create({ ...req.body, createdById: req.user!.id }, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(supplier, 'تم إنشاء المورد بنجاح - Supplier created successfully'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supplier = await suppliersService.update(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(supplier, 'تم تحديث المورد بنجاح - Supplier updated successfully'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async archive(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supplier = await suppliersService.archive(req.params.id, req.user!.id, req.body.reason);
|
||||
res.json(ResponseFormatter.success(supplier, 'تم أرشفة المورد بنجاح - Supplier archived successfully'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async export(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
status: req.query.status as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
category: req.query.category as string,
|
||||
};
|
||||
|
||||
const buffer = await suppliersService.export(filters);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=suppliers-${Date.now()}.xlsx`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const suppliersController = new SuppliersController();
|
||||
66
backend/src/modules/suppliers/suppliers.routes.ts
Normal file
66
backend/src/modules/suppliers/suppliers.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
import { suppliersController } from './suppliers.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', authorize('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;
|
||||
314
backend/src/modules/suppliers/suppliers.service.ts
Normal file
314
backend/src/modules/suppliers/suppliers.service.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import prisma from '../../config/database';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { contactsService } from '../contacts/contacts.service';
|
||||
|
||||
interface SupplierFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
rating?: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface SupplierContactData {
|
||||
name?: string;
|
||||
nameAr?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
companyName?: string;
|
||||
companyNameAr?: string;
|
||||
taxNumber?: string;
|
||||
commercialRegister?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
rating?: number;
|
||||
status?: string;
|
||||
customFields?: any;
|
||||
createdById?: string;
|
||||
}
|
||||
|
||||
class SuppliersService {
|
||||
private supplierCategoryNames = ['Supplier', 'Suppliers'];
|
||||
|
||||
private isSupplierSystemCategory(category: any) {
|
||||
return (
|
||||
this.supplierCategoryNames.includes(category?.name) ||
|
||||
Boolean(category?.nameAr && String(category.nameAr).includes('مورد'))
|
||||
);
|
||||
}
|
||||
|
||||
private getSupplierCategoryLabels(supplier: any): string[] {
|
||||
const customFields = supplier.customFields || {};
|
||||
|
||||
if (Array.isArray(customFields.supplierCategories)) {
|
||||
return customFields.supplierCategories
|
||||
.map((category: any) => String(category || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (customFields.supplierCategory) return [String(customFields.supplierCategory)];
|
||||
|
||||
return (supplier.categories || [])
|
||||
.filter((category: any) => !this.isSupplierSystemCategory(category))
|
||||
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
private async ensureSupplierCategory() {
|
||||
const existing = await prisma.contactCategory.findFirst({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ name: { in: this.supplierCategoryNames } },
|
||||
{ nameAr: { contains: 'مورد' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
return prisma.contactCategory.create({
|
||||
data: {
|
||||
name: 'Supplier',
|
||||
nameAr: 'مورّد',
|
||||
description: 'Supplier / vendor records managed from Supplier Management module',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private supplierCondition(): Prisma.ContactWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ type: 'SUPPLIER' },
|
||||
{
|
||||
categories: {
|
||||
some: {
|
||||
OR: [
|
||||
{ name: { in: this.supplierCategoryNames } },
|
||||
{ nameAr: { contains: 'مورد' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildWhere(filters: SupplierFilters = {}): Prisma.ContactWhereInput {
|
||||
const andConditions: Prisma.ContactWhereInput[] = [
|
||||
{ archivedAt: null },
|
||||
this.supplierCondition(),
|
||||
];
|
||||
|
||||
if (filters.search) {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ phone: { contains: filters.search } },
|
||||
{ mobile: { contains: filters.search } },
|
||||
{ companyName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ companyNameAr: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ taxNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ commercialRegister: { contains: filters.search, mode: 'insensitive' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.status) andConditions.push({ status: filters.status });
|
||||
if (filters.rating !== undefined) andConditions.push({ rating: filters.rating });
|
||||
if (filters.category) {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{
|
||||
customFields: {
|
||||
path: ['supplierCategories'],
|
||||
array_contains: [filters.category],
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
customFields: {
|
||||
path: ['supplierCategory'],
|
||||
equals: filters.category,
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
categories: {
|
||||
some: {
|
||||
OR: [
|
||||
{ name: { equals: filters.category, mode: 'insensitive' } },
|
||||
{ nameAr: { equals: filters.category } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return { AND: andConditions };
|
||||
}
|
||||
|
||||
private isSupplierContact(contact: any) {
|
||||
return (
|
||||
contact.type === 'SUPPLIER' ||
|
||||
contact.categories?.some((category: any) => this.isSupplierSystemCategory(category))
|
||||
);
|
||||
}
|
||||
|
||||
async findAll(filters: SupplierFilters, page: number = 1, pageSize: number = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = this.buildWhere(filters);
|
||||
|
||||
const [total, suppliers] = await Promise.all([
|
||||
prisma.contact.count({ where }),
|
||||
prisma.contact.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
categories: true,
|
||||
parent: { select: { id: true, name: true, type: true } },
|
||||
createdBy: { select: { id: true, email: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { suppliers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const supplier = await prisma.contact.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
createdBy: { select: { id: true, email: true, username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!supplier || supplier.archivedAt || !this.isSupplierContact(supplier)) {
|
||||
throw new AppError(404, 'المورد غير موجود - Supplier not found');
|
||||
}
|
||||
|
||||
return supplier;
|
||||
}
|
||||
|
||||
async create(data: SupplierContactData, userId: string) {
|
||||
const supplierCategory = await this.ensureSupplierCategory();
|
||||
const categories = [supplierCategory.id];
|
||||
|
||||
return contactsService.create(
|
||||
{
|
||||
...data,
|
||||
type: 'SUPPLIER',
|
||||
name: data.name || data.companyName || 'Supplier',
|
||||
companyName: data.companyName || data.name,
|
||||
country: data.country || 'Syria',
|
||||
source: data.source || 'SUPPLIER_MODULE',
|
||||
categories,
|
||||
createdById: data.createdById || userId,
|
||||
},
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, data: SupplierContactData, userId: string) {
|
||||
const existing = await this.findById(id);
|
||||
const supplierCategory = await this.ensureSupplierCategory();
|
||||
const categories = [supplierCategory.id];
|
||||
|
||||
return contactsService.update(
|
||||
id,
|
||||
{
|
||||
...data,
|
||||
type: 'SUPPLIER',
|
||||
name: data.name || data.companyName || existing.name,
|
||||
companyName: data.companyName || data.name || existing.companyName || undefined,
|
||||
categories,
|
||||
},
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string, reason?: string) {
|
||||
await this.findById(id);
|
||||
return contactsService.archive(id, userId, reason || 'Archived from Supplier Management');
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const [total, active, inactive, blocked] = await Promise.all([
|
||||
prisma.contact.count({ where: this.buildWhere() }),
|
||||
prisma.contact.count({ where: this.buildWhere({ status: 'ACTIVE' }) }),
|
||||
prisma.contact.count({ where: this.buildWhere({ status: 'INACTIVE' }) }),
|
||||
prisma.contact.count({ where: this.buildWhere({ status: 'BLOCKED' }) }),
|
||||
]);
|
||||
return { total, active, inactive, blocked };
|
||||
}
|
||||
|
||||
async export(filters: SupplierFilters): Promise<Buffer> {
|
||||
const xlsx = require('xlsx');
|
||||
const suppliers = await prisma.contact.findMany({
|
||||
where: this.buildWhere(filters),
|
||||
include: {
|
||||
categories: true,
|
||||
createdBy: { select: { username: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const exportData = suppliers.map((supplier) => {
|
||||
const customFields: any = supplier.customFields || {};
|
||||
const categoryNames = this.getSupplierCategoryLabels(supplier).join(', ');
|
||||
|
||||
return {
|
||||
'Supplier ID': supplier.uniqueContactId,
|
||||
'Supplier Code': customFields.supplierCode || '',
|
||||
'Supplier Name': supplier.companyName || supplier.name,
|
||||
'Supplier Name (Arabic)': supplier.companyNameAr || supplier.nameAr || '',
|
||||
'Contact Person': supplier.name || '',
|
||||
'Email': supplier.email || '',
|
||||
'Phone': supplier.phone || '',
|
||||
'Mobile': supplier.mobile || '',
|
||||
'Website': supplier.website || '',
|
||||
'Tax Number': supplier.taxNumber || '',
|
||||
'Commercial Register': supplier.commercialRegister || '',
|
||||
'Categories': categoryNames,
|
||||
'Payment Terms': customFields.paymentTerms || '',
|
||||
'Bank Name': customFields.bankName || '',
|
||||
'Bank Account': customFields.bankAccount || '',
|
||||
'Address': supplier.address || '',
|
||||
'City': supplier.city || '',
|
||||
'Country': supplier.country || '',
|
||||
'Status': supplier.status,
|
||||
'Rating': supplier.rating || '',
|
||||
'Notes': customFields.notes || '',
|
||||
'Created By': supplier.createdBy?.username || supplier.createdBy?.email || '',
|
||||
'Created At': supplier.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const worksheet = xlsx.utils.json_to_sheet(exportData);
|
||||
const workbook = xlsx.utils.book_new();
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, 'Suppliers');
|
||||
worksheet['!cols'] = [
|
||||
{ wch: 16 }, { wch: 18 }, { wch: 28 }, { wch: 28 }, { wch: 24 }, { wch: 30 },
|
||||
{ wch: 16 }, { wch: 16 }, { wch: 24 }, { wch: 18 }, { wch: 22 }, { wch: 18 },
|
||||
{ wch: 18 }, { wch: 22 }, { wch: 26 }, { wch: 30 }, { wch: 16 }, { wch: 16 },
|
||||
{ wch: 12 }, { wch: 12 }, { wch: 30 }, { wch: 18 }, { wch: 24 },
|
||||
];
|
||||
|
||||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
}
|
||||
|
||||
export const suppliersService = new SuppliersService();
|
||||
@@ -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) {
|
||||
try {
|
||||
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) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
@@ -236,8 +263,12 @@ export class TendersController {
|
||||
|
||||
const fs = require('fs')
|
||||
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(
|
||||
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))
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error('[tenders.viewAttachment]', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||
// 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({
|
||||
@@ -56,6 +65,16 @@ router.get(
|
||||
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(
|
||||
'/check-duplicates',
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
@@ -83,6 +102,7 @@ router.post(
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
[
|
||||
body('tenderNumber').notEmpty().trim(),
|
||||
body('issueNumber').optional().trim(),
|
||||
body('issuingBodyName').notEmpty().trim(),
|
||||
body('title').notEmpty().trim(),
|
||||
body('termsValue').isNumeric(),
|
||||
@@ -112,6 +132,14 @@ router.put(
|
||||
tendersController.update
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
authorize('tenders', 'tenders', 'delete'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
tendersController.delete
|
||||
);
|
||||
|
||||
// Tender history
|
||||
router.get(
|
||||
'/:id/history',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import path from 'path';
|
||||
import fs from 'fs'
|
||||
import { config } from '../../config';
|
||||
|
||||
|
||||
const TENDER_SOURCE_VALUES = [
|
||||
@@ -35,6 +36,7 @@ export interface CreateTenderData {
|
||||
issuingBodyName: string;
|
||||
title: string;
|
||||
tenderNumber: string;
|
||||
issueNumber?: string;
|
||||
|
||||
termsValue: number;
|
||||
bondValue: number;
|
||||
@@ -86,8 +88,52 @@ class TendersService {
|
||||
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 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) {
|
||||
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(
|
||||
plainNotes?: string | null,
|
||||
extra?: {
|
||||
@@ -152,11 +254,17 @@ class TendersService {
|
||||
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
||||
}
|
||||
|
||||
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
|
||||
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
||||
private mapTenderExtraFields<T extends {
|
||||
notes?: string | null;
|
||||
bondValue?: any;
|
||||
status?: string | null;
|
||||
closingDate?: Date | string | null;
|
||||
}>(tender: T) {
|
||||
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
||||
|
||||
return {
|
||||
...tender,
|
||||
status: this.getEffectiveTenderStatus(tender),
|
||||
notes: cleanNotes || null,
|
||||
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
||||
finalBondValue: meta.finalBondValue ?? null,
|
||||
@@ -246,6 +354,7 @@ class TendersService {
|
||||
const tender = await prisma.tender.create({
|
||||
data: {
|
||||
tenderNumber,
|
||||
issueNumber: data.issueNumber?.trim() || null,
|
||||
issuingBodyName: data.issuingBodyName.trim(),
|
||||
title: data.title.trim(),
|
||||
termsValue: data.termsValue,
|
||||
@@ -285,11 +394,20 @@ class TendersService {
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ title: { 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.announcementType) where.announcementType = filters.announcementType;
|
||||
|
||||
@@ -381,6 +499,9 @@ class TendersService {
|
||||
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
||||
}
|
||||
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.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
||||
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
||||
@@ -555,6 +676,27 @@ class TendersService {
|
||||
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(
|
||||
tenderId: string,
|
||||
data: { contactId: string; pipelineId: string; ownerId?: string },
|
||||
@@ -568,6 +710,9 @@ class TendersService {
|
||||
if (tender.status === '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({
|
||||
where: { id: data.pipelineId },
|
||||
@@ -630,7 +775,27 @@ class TendersService {
|
||||
) {
|
||||
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
||||
if (!tender) throw new AppError(404, 'Tender not found');
|
||||
const fileName = path.basename(file.path);
|
||||
|
||||
const 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({
|
||||
data: {
|
||||
entityType: 'TENDER',
|
||||
@@ -640,11 +805,12 @@ class TendersService {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
path: absolutePath,
|
||||
category: category || 'ANNOUNCEMENT',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TENDER',
|
||||
entityId: tenderId,
|
||||
@@ -652,6 +818,7 @@ class TendersService {
|
||||
userId,
|
||||
changes: { attachmentUploaded: attachment.id },
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -665,8 +832,29 @@ class TendersService {
|
||||
where: { id: directiveId },
|
||||
select: { id: true, tenderId: true },
|
||||
});
|
||||
|
||||
if (!directive) throw new AppError(404, 'Directive not found');
|
||||
const fileName = path.basename(file.path);
|
||||
|
||||
const 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({
|
||||
data: {
|
||||
entityType: 'TENDER_DIRECTIVE',
|
||||
@@ -677,11 +865,12 @@ class TendersService {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
path: absolutePath,
|
||||
category: category || 'TASK_FILE',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TENDER_DIRECTIVE',
|
||||
entityId: directiveId,
|
||||
@@ -689,17 +878,62 @@ class TendersService {
|
||||
userId,
|
||||
changes: { attachmentUploaded: attachment.id },
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
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> {
|
||||
@@ -709,12 +943,10 @@ class TendersService {
|
||||
|
||||
if (!attachment) throw new AppError(404, 'File not found')
|
||||
|
||||
// حذف من الديسك
|
||||
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||
fs.unlinkSync(attachment.path)
|
||||
}
|
||||
|
||||
// حذف من DB
|
||||
await prisma.attachment.delete({
|
||||
where: { id: attachmentId },
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||
import projectsRoutes from '../modules/projects/projects.routes';
|
||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||
import tendersRoutes from '../modules/tenders/tenders.routes';
|
||||
import notificationsRoutes from '../modules/notifications/notifications.routes';
|
||||
import suppliersRoutes from '../modules/suppliers/suppliers.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -17,12 +19,14 @@ router.use('/admin', adminRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/suppliers', suppliersRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
router.use('/hr', hrRoutes);
|
||||
router.use('/inventory', inventoryRoutes);
|
||||
router.use('/projects', projectsRoutes);
|
||||
router.use('/marketing', marketingRoutes);
|
||||
router.use('/tenders', tendersRoutes);
|
||||
router.use('/notifications', notificationsRoutes);
|
||||
|
||||
// API info
|
||||
router.get('/', (req, res) => {
|
||||
@@ -33,6 +37,7 @@ router.get('/', (req, res) => {
|
||||
modules: [
|
||||
'Auth',
|
||||
'Contact Management',
|
||||
'Supplier Management',
|
||||
'CRM',
|
||||
'HR Management',
|
||||
'Inventory & Assets',
|
||||
|
||||
@@ -34,8 +34,12 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
||||
JWT_EXPIRES_IN: 7d
|
||||
JWT_REFRESH_EXPIRES_IN: 30d
|
||||
MAX_FILE_SIZE: 52428800
|
||||
UPLOAD_PATH: /app/uploads
|
||||
BCRYPT_ROUNDS: 10
|
||||
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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -67,3 +71,5 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 101 KiB |
@@ -9,13 +9,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||
{ id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
|
||||
{ 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_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: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
@@ -28,6 +38,8 @@ const ACTIONS = [
|
||||
{ id: 'delete', name: 'حذف' },
|
||||
{ id: 'export', name: 'تصدير' },
|
||||
{ id: 'approve', name: 'اعتماد' },
|
||||
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||
{ id: 'notify', name: 'إشعار' },
|
||||
{ id: 'merge', name: 'دمج' },
|
||||
];
|
||||
|
||||
|
||||
@@ -10,13 +10,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
|
||||
{ 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_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: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
@@ -29,6 +39,8 @@ const ACTIONS = [
|
||||
{ id: 'delete', name: 'حذف' },
|
||||
{ id: 'export', name: 'تصدير' },
|
||||
{ id: 'approve', name: 'اعتماد' },
|
||||
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||
{ id: 'notify', name: 'إشعار' },
|
||||
{ id: 'merge', name: 'دمج' },
|
||||
];
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ function ContactDetailContent() {
|
||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||
UN: 'bg-sky-100 text-sky-700',
|
||||
NGO: 'bg-pink-100 text-pink-700',
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700',
|
||||
SUPPLIER: 'bg-emerald-100 text-emerald-700'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
@@ -124,7 +125,8 @@ function ContactDetailContent() {
|
||||
SCHOOL: 'مدارس - Schools',
|
||||
UN: 'UN - United Nations',
|
||||
NGO: 'NGO - Non-Governmental Organization',
|
||||
INSTITUTION: 'مؤسسة - Institution'
|
||||
INSTITUTION: 'مؤسسة - Institution',
|
||||
SUPPLIER: 'مورّد - Supplier'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
@@ -370,7 +372,7 @@ function ContactDetailContent() {
|
||||
{ id: 'address', label: 'Address', icon: MapPin },
|
||||
{ id: 'categories', label: 'Categories & Tags', icon: Tag },
|
||||
{ id: 'relationships', label: 'Relationships', icon: Users },
|
||||
...((contact.type === 'COMPANY' || contact.type === 'HOLDING')
|
||||
...((['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type))
|
||||
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
|
||||
: []
|
||||
),
|
||||
@@ -646,7 +648,7 @@ function ContactDetailContent() {
|
||||
)}
|
||||
|
||||
{/* Hierarchy Tab */}
|
||||
{activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && (
|
||||
{activeTab === 'hierarchy' && (['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) && (
|
||||
<div>
|
||||
<HierarchyTree rootContactId={contactId} />
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
|
||||
import { categoriesAPI, Category } from '@/lib/api/categories'
|
||||
import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories'
|
||||
import ContactForm from '@/components/contacts/ContactForm'
|
||||
import ContactImport from '@/components/contacts/ContactImport'
|
||||
|
||||
@@ -78,6 +79,7 @@ function ContactsContent() {
|
||||
const filters: ContactFilters = {
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
excludeSuppliers: true,
|
||||
}
|
||||
|
||||
if (searchTerm) filters.search = searchTerm
|
||||
@@ -192,7 +194,8 @@ function ContactsContent() {
|
||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||
UN: 'bg-sky-100 text-sky-700',
|
||||
NGO: 'bg-pink-100 text-pink-700',
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700',
|
||||
SUPPLIER: 'bg-emerald-100 text-emerald-700'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
@@ -214,7 +217,8 @@ function ContactsContent() {
|
||||
SCHOOL: 'مدارس',
|
||||
UN: 'UN',
|
||||
NGO: 'NGO',
|
||||
INSTITUTION: 'مؤسسة'
|
||||
INSTITUTION: 'مؤسسة',
|
||||
SUPPLIER: 'مورّد'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
@@ -231,6 +235,7 @@ function ContactsContent() {
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
'SUPPLIER',
|
||||
])
|
||||
|
||||
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"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{flattenCategories(categories).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
||||
))}
|
||||
{flattenCategories(categories)
|
||||
.filter((cat) => !isSupplierOnlyCategoryName(cat.name, cat.nameAr))
|
||||
.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -582,13 +589,19 @@ function ContactsContent() {
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
{getListCompanyName(contact) !== '-' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{getListCompanyName(contact)}
|
||||
</span>
|
||||
{getListCompanyName(contact) !== '-' ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
{getListCompanyName(contact).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{getListCompanyName(contact)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -609,13 +622,9 @@ function ContactsContent() {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<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>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<p className="text-sm text-gray-900">
|
||||
{getListContactName(contact)}
|
||||
</p>
|
||||
{getListContactNameAr(contact) && (
|
||||
@@ -624,8 +633,7 @@ function ContactsContent() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
|
||||
<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)}`}>
|
||||
@@ -819,6 +827,7 @@ function ContactsContent() {
|
||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||
if (selectedCategory !== 'all') filters.category = selectedCategory
|
||||
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
|
||||
filters.excludeSuppliers = true
|
||||
|
||||
const blob = await contactsAPI.export(filters)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import logoImage from '@/assets/logo.png'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Users,
|
||||
User,
|
||||
@@ -21,23 +22,229 @@ import {
|
||||
Settings,
|
||||
Bell,
|
||||
Shield,
|
||||
FileText
|
||||
FileText,
|
||||
Truck
|
||||
} 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() {
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
const router = useRouter()
|
||||
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(() => {
|
||||
dashboardAPI.getStats()
|
||||
dashboardAPI
|
||||
.getStats()
|
||||
.then((res) => {
|
||||
if (res.data?.data) setStats(res.data.data)
|
||||
})
|
||||
.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 = [
|
||||
{
|
||||
id: 'contacts',
|
||||
@@ -46,9 +253,19 @@ function DashboardContent() {
|
||||
icon: Users,
|
||||
color: 'bg-blue-500',
|
||||
href: '/contacts',
|
||||
description: 'إدارة العملاء والموردين وجهات الاتصال',
|
||||
description: 'إدارة العملاء وجهات الاتصال',
|
||||
permission: 'contacts'
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
name: 'إدارة الموردين',
|
||||
nameEn: 'Supplier Management',
|
||||
icon: Truck,
|
||||
color: 'bg-emerald-500',
|
||||
href: '/suppliers',
|
||||
description: 'إدارة الموردين وبيانات التواصل والاعتماد',
|
||||
permission: 'suppliers'
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
name: 'إدارة علاقات العملاء',
|
||||
@@ -135,6 +352,9 @@ function DashboardContent() {
|
||||
const availableModules = allModules.filter(module =>
|
||||
hasPermission(module.permission, 'view')
|
||||
)
|
||||
const canViewManagedLeaves = hasPermission('department_leave_requests', 'view')
|
||||
const canViewManagedOvertime = hasPermission('department_overtime_requests', 'view')
|
||||
const canApproveHr = hasPermission('hr', 'approve')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
@@ -182,12 +402,86 @@ function DashboardContent() {
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<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>
|
||||
<div className="relative" ref={notificationsRef}>
|
||||
<button
|
||||
onClick={handleToggleNotifications}
|
||||
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 */}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
@@ -268,6 +562,65 @@ function DashboardContent() {
|
||||
</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 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import Modal from '@/components/Modal'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
@@ -245,6 +246,8 @@ function EmployeeFormFields({
|
||||
|
||||
function HRContent() {
|
||||
// State Management
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -296,7 +299,13 @@ function HRContent() {
|
||||
const [loadingDepts, setLoadingDepts] = useState(false)
|
||||
|
||||
// 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 [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
||||
|
||||
@@ -391,6 +400,24 @@ function HRContent() {
|
||||
}
|
||||
}, [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)
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -721,7 +748,7 @@ function HRContent() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('employees')}
|
||||
onClick={() => openTab('employees')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'employees'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -734,7 +761,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('departments')}
|
||||
onClick={() => openTab('departments')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'departments'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -747,7 +774,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('orgchart')}
|
||||
onClick={() => openTab('orgchart')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'orgchart'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -760,7 +787,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('leaves')}
|
||||
onClick={() => openTab('leaves')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'leaves'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -773,7 +800,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('loans')}
|
||||
onClick={() => openTab('loans')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'loans'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -786,7 +813,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('purchases')}
|
||||
onClick={() => openTab('purchases')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'purchases'
|
||||
? 'border-red-600 text-red-600'
|
||||
@@ -799,7 +826,7 @@ function HRContent() {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('contracts')}
|
||||
onClick={() => openTab('contracts')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'contracts'
|
||||
? 'border-red-600 text-red-600'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TrendingUp,
|
||||
Package,
|
||||
CheckSquare,
|
||||
Truck,
|
||||
LogIn
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -39,7 +40,12 @@ export default function Home() {
|
||||
{
|
||||
icon: Users,
|
||||
title: 'إدارة جهات الاتصال',
|
||||
description: 'نظام شامل لإدارة العملاء والموردين وجهات الاتصال'
|
||||
description: 'نظام شامل لإدارة العملاء وجهات الاتصال'
|
||||
},
|
||||
{
|
||||
icon: Truck,
|
||||
title: 'إدارة الموردين',
|
||||
description: 'وحدة مستقلة لإدارة الموردين وبيانات الاعتماد والتواصل'
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
|
||||
789
frontend/src/app/portal/expense-claims/page.tsx
Normal file
789
frontend/src/app/portal/expense-claims/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Building2,
|
||||
LogOut,
|
||||
User,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
TimerReset,
|
||||
} from 'lucide-react'
|
||||
@@ -26,6 +27,7 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
||||
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
||||
{ 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' },
|
||||
...(hasPermission('department_overtime_requests', 'view')
|
||||
? [{
|
||||
@@ -35,12 +37,20 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
href: '/portal/managed-overtime-requests'
|
||||
}]
|
||||
: []),
|
||||
...(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' },
|
||||
]
|
||||
...(hasPermission('department_expense_claims', 'view')
|
||||
? [{
|
||||
icon: CheckCircle2,
|
||||
label: 'طلبات كشوف المصاريف',
|
||||
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) => {
|
||||
if (exact) return pathname === href
|
||||
|
||||
@@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { toast } from 'react-hot-toast'
|
||||
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 = [
|
||||
{ value: 'ANNUAL', 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' },
|
||||
}
|
||||
|
||||
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() {
|
||||
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
|
||||
const [leaves, setLeaves] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
leaveType: 'ANNUAL',
|
||||
@@ -48,6 +77,66 @@ export default function PortalLeavePage() {
|
||||
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -80,28 +169,35 @@ export default function PortalLeavePage() {
|
||||
return
|
||||
}
|
||||
|
||||
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
|
||||
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
|
||||
}
|
||||
payload.leaveDate = form.leaveDate
|
||||
payload.startTime = form.startTime
|
||||
payload.endTime = form.endTime
|
||||
payload.startDate = toCompanyDateTime(form.leaveDate, form.startTime)
|
||||
payload.endDate = toCompanyDateTime(form.leaveDate, form.endTime)
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
portalAPI.submitLeaveRequest(payload)
|
||||
const action = editingId
|
||||
? portalAPI.updateLeaveRequest(editingId, payload)
|
||||
: portalAPI.submitLeaveRequest(payload)
|
||||
|
||||
action
|
||||
.then(() => {
|
||||
setShowModal(false)
|
||||
setForm({
|
||||
leaveType: 'ANNUAL',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
leaveDate: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
reason: '',
|
||||
})
|
||||
toast.success('تم إرسال طلب الإجازة')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -114,7 +210,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -156,18 +252,38 @@ export default function PortalLeavePage() {
|
||||
<p className="font-medium">
|
||||
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
|
||||
{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} يوم`}
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
@@ -176,7 +292,11 @@ export default function PortalLeavePage() {
|
||||
</div>
|
||||
|
||||
{/* الفورم */}
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{/* نوع الإجازة */}
|
||||
@@ -238,22 +358,30 @@ export default function PortalLeavePage() {
|
||||
|
||||
<div>
|
||||
<label className="text-sm">من الساعة</label>
|
||||
<input
|
||||
type="time"
|
||||
<select
|
||||
value={form.startTime}
|
||||
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
|
||||
className="border p-2 rounded w-full"
|
||||
/>
|
||||
>
|
||||
<option value="">اختر الوقت</option>
|
||||
{TIME_OPTIONS.map((time) => (
|
||||
<option key={time} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm">إلى الساعة</label>
|
||||
<input
|
||||
type="time"
|
||||
<select
|
||||
value={form.endTime}
|
||||
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
|
||||
className="border p-2 rounded w-full"
|
||||
/>
|
||||
>
|
||||
<option value="">اختر الوقت</option>
|
||||
{TIME_OPTIONS.map((time) => (
|
||||
<option key={time} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -270,7 +398,7 @@ export default function PortalLeavePage() {
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
onClick={() => { setShowModal(false); resetForm() }}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -281,7 +409,7 @@ export default function PortalLeavePage() {
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = 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: '' })
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
|
||||
.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) => {
|
||||
e.preventDefault()
|
||||
const amount = parseFloat(form.amount)
|
||||
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
portalAPI.submitLoanRequest({
|
||||
const payload = {
|
||||
type: form.type,
|
||||
amount,
|
||||
installments: parseInt(form.installments) || 1,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
}
|
||||
const action = editingId
|
||||
? portalAPI.updateLoanRequest(editingId, payload)
|
||||
: portalAPI.submitLoanRequest(payload)
|
||||
action
|
||||
.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)
|
||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||
toast.success('تم إرسال طلب القرض')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{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>
|
||||
{loan.rejectedReason && (
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
||||
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
||||
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
|
||||
إلغاء
|
||||
</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">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
575
frontend/src/app/portal/managed-expense-claims/page.tsx
Normal file
575
frontend/src/app/portal/managed-expense-claims/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,23 @@ import { toast } from 'react-hot-toast'
|
||||
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
|
||||
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() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
|
||||
@@ -136,15 +153,15 @@ export default function ManagedLeavesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p>{new Date(leave.startDate).toLocaleString()}</p>
|
||||
<p>{new Date(leave.endDate).toLocaleString()}</p>
|
||||
<p>{formatCompanyDateTime(leave.startDate)}</p>
|
||||
<p>{formatCompanyDateTime(leave.endDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-gray-900">
|
||||
{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} يوم`}
|
||||
</td>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
date: '',
|
||||
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await portalAPI.submitOvertimeRequest({
|
||||
date: form.date,
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم إرسال الطلب')
|
||||
if (editingId) {
|
||||
await portalAPI.updateOvertimeRequest(editingId, {
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم تعديل الطلب')
|
||||
} else {
|
||||
await portalAPI.submitOvertimeRequest({
|
||||
date: form.date,
|
||||
hours,
|
||||
reason: form.reason.trim(),
|
||||
})
|
||||
toast.success('تم إرسال الطلب')
|
||||
}
|
||||
setOpen(false)
|
||||
setForm({ date: '', hours: '', reason: '' })
|
||||
resetForm()
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => { setOpen(false); resetForm() }}
|
||||
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
disabled={!!editingId}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => { setOpen(false); resetForm() }}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
إلغاء
|
||||
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
||||
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'
|
||||
|
||||
export default function PortalDashboardPage() {
|
||||
@@ -25,7 +25,7 @@ export default function PortalDashboardPage() {
|
||||
|
||||
const { employee, stats } = data
|
||||
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||
|
||||
const canViewDepartmentExpenseClaims = hasPermission('department_expense_claims', 'view')
|
||||
const name = employee.firstNameAr && employee.lastNameAr
|
||||
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
||||
: `${employee.firstName} ${employee.lastName}`
|
||||
@@ -89,12 +89,27 @@ export default function PortalDashboardPage() {
|
||||
</Link>
|
||||
</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="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">
|
||||
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
|
||||
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount + stats.pendingExpenseClaimsCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-3 rounded-lg">
|
||||
@@ -104,6 +119,7 @@ export default function PortalDashboardPage() {
|
||||
<div className="mt-4 flex gap-4">
|
||||
<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/expense-claims" className="text-sm text-blue-600 hover:underline">المصاريف</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +187,11 @@ export default function PortalDashboardPage() {
|
||||
</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">
|
||||
<Plus className="h-4 w-4" />
|
||||
طلب شراء
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||
reason: '',
|
||||
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
const items = form.items
|
||||
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
portalAPI.submitPurchaseRequest({
|
||||
items,
|
||||
reason: form.reason || undefined,
|
||||
priority: form.priority,
|
||||
})
|
||||
const payload = { items, reason: form.reason || undefined, priority: form.priority }
|
||||
const action = editingId
|
||||
? portalAPI.updatePurchaseRequest(editingId, payload)
|
||||
: portalAPI.submitPurchaseRequest(payload)
|
||||
action
|
||||
.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)
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
toast.success('تم إرسال طلب الشراء')
|
||||
resetForm()
|
||||
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{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>
|
||||
)
|
||||
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
|
||||
</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">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
|
||||
إلغاء
|
||||
</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">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
215
frontend/src/app/suppliers/[id]/page.tsx
Normal file
215
frontend/src/app/suppliers/[id]/page.tsx
Normal 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>
|
||||
}
|
||||
663
frontend/src/app/suppliers/page.tsx
Normal file
663
frontend/src/app/suppliers/page.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
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 { toast } from 'react-hot-toast'
|
||||
import {
|
||||
@@ -24,7 +25,6 @@ import Modal from '@/components/Modal'
|
||||
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
|
||||
import { contactsAPI } from '@/lib/api/contacts'
|
||||
import { pipelinesAPI } from '@/lib/api/pipelines'
|
||||
import { employeesAPI } from '@/lib/api/employees'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -34,7 +34,26 @@ const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
||||
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() {
|
||||
const searchParams = useSearchParams()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const tenderId = params.id as string
|
||||
@@ -43,7 +62,12 @@ function TenderDetailContent() {
|
||||
const [tender, setTender] = useState<Tender | null>(null)
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
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 [showConvertModal, setShowConvertModal] = useState(false)
|
||||
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
|
||||
@@ -63,10 +87,13 @@ function TenderDetailContent() {
|
||||
const [completeNotes, setCompleteNotes] = useState('')
|
||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingDirectiveId, setUploadingDirectiveId] = 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 () => {
|
||||
try {
|
||||
@@ -85,6 +112,15 @@ function TenderDetailContent() {
|
||||
setHistory(data)
|
||||
} 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(() => {
|
||||
fetchTender()
|
||||
@@ -100,9 +136,12 @@ function TenderDetailContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (showDirectiveModal || showConvertModal) {
|
||||
employeesAPI
|
||||
.getAll({ status: 'ACTIVE', pageSize: 500 })
|
||||
.then((r: any) => setEmployees(r.employees || []))
|
||||
// Use the directive-scoped employee list so non-HR users (with
|
||||
// tenders:directives:create) can populate this dropdown without
|
||||
// being granted hr:employees:read (which would leak salaries etc.).
|
||||
tendersAPI
|
||||
.getAssignableEmployees()
|
||||
.then((list) => setEmployees(list))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -177,19 +216,42 @@ function TenderDetailContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const handleTenderFileUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
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 {
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||
toast.success(t('tenders.uploadFile'))
|
||||
fetchTender()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Upload failed')
|
||||
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||
for (const file of files) {
|
||||
try {
|
||||
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
|
||||
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 {
|
||||
setSubmitting(false)
|
||||
setUploadingCategory(null)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
@@ -200,20 +262,35 @@ function TenderDetailContent() {
|
||||
}
|
||||
|
||||
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
const files = Array.from(e.target.files || [])
|
||||
const directiveId = directiveIdForUpload
|
||||
e.target.value = ''
|
||||
setDirectiveIdForUpload(null)
|
||||
|
||||
if (!file || !directiveId) return
|
||||
if (!files.length || !directiveId) return
|
||||
|
||||
setUploadingDirectiveId(directiveId)
|
||||
let successCount = 0
|
||||
|
||||
try {
|
||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||
toast.success(t('tenders.uploadFile'))
|
||||
fetchTender()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Upload failed')
|
||||
for (const file of files) {
|
||||
try {
|
||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||
successCount++
|
||||
} 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 {
|
||||
setUploadingDirectiveId(null)
|
||||
}
|
||||
@@ -266,7 +343,7 @@ function TenderDetailContent() {
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
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 ${
|
||||
activeTab === tab.id
|
||||
? 'bg-indigo-100 text-indigo-800'
|
||||
@@ -428,6 +505,7 @@ function TenderDetailContent() {
|
||||
<input
|
||||
type="file"
|
||||
ref={directiveFileInputRef}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleDirectiveFileUpload}
|
||||
/>
|
||||
@@ -455,65 +533,102 @@ function TenderDetailContent() {
|
||||
|
||||
{activeTab === 'attachments' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleTenderFileUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t('tenders.uploadFile')}
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const all = (tender.attachments || []) as any[]
|
||||
const sections: Array<{
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
}> = [
|
||||
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
|
||||
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
|
||||
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
|
||||
]
|
||||
|
||||
{!tender.attachments?.length ? (
|
||||
<p className="text-gray-500">{t('common.noData')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tender.attachments.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" />
|
||||
{a.originalName || a.fileName}
|
||||
</a>
|
||||
// Legacy attachments without a recognized category live under
|
||||
// the dafter section by default so nothing gets hidden.
|
||||
const knownCategories = new Set(sections.map((s) => s.category))
|
||||
const inSection = (a: any, category: string) =>
|
||||
a.category === category ||
|
||||
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
|
||||
|
||||
<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>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sections.map((section) => {
|
||||
const items = all.filter((a) => inSection(a, section.category))
|
||||
const isUploading = uploadingCategory === section.category
|
||||
return (
|
||||
<div key={section.key} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
ref={section.ref}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleTenderFileUpload(e, section.category)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
|
||||
import { categoriesAPI, Category } from '@/lib/api/categories'
|
||||
import { filterContactCategoryTree } from '@/lib/supplierCategories'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface CategorySelectorProps {
|
||||
selectedIds: string[]
|
||||
onChange: (selectedIds: string[]) => void
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
@@ -25,11 +27,28 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await categoriesAPI.getTree()
|
||||
setCategories(data)
|
||||
setCategories(filterContactCategoryTree(data))
|
||||
} catch (error) {
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
@@ -102,6 +121,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
{/* Expand/Collapse */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand(category.id)
|
||||
@@ -127,6 +147,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
|
||||
{/* Category Name */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSelect(category.id)}
|
||||
className="flex-1 text-left flex items-center gap-2"
|
||||
>
|
||||
@@ -179,7 +200,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
}
|
||||
|
||||
return selectedIds
|
||||
.map(id => findCategory(categories, id))
|
||||
.map(id => findCategory(visibleCategories, id))
|
||||
.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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
title="Add Category"
|
||||
@@ -221,6 +243,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
>
|
||||
{category.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSelected(category.id)}
|
||||
className="hover:text-blue-900"
|
||||
>
|
||||
@@ -233,11 +256,12 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
|
||||
{/* Category Tree */}
|
||||
<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">
|
||||
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>No categories found</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
@@ -245,7 +269,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
categories.map(category => renderCategory(category))
|
||||
visibleCategories.map(category => renderCategory(category))
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<option value="">None (Root Category)</option>
|
||||
{categories.map(cat => (
|
||||
{visibleCategories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -305,6 +329,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddModal(false)
|
||||
setNewCategoryName('')
|
||||
@@ -316,6 +341,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCategory}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
'SUPPLIER',
|
||||
])
|
||||
|
||||
const isOrganizationType = organizationTypes.has(formData.type)
|
||||
@@ -126,7 +127,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
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']
|
||||
|
||||
// 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="NGO">NGO - منظمة غير حكومية</option>
|
||||
<option value="INSTITUTION">Institution - مؤسسة</option>
|
||||
<option value="SUPPLIER">Supplier - مورّد</option>
|
||||
</select>
|
||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||
</div>
|
||||
|
||||
168
frontend/src/components/suppliers/SupplierCategorySelector.tsx
Normal file
168
frontend/src/components/suppliers/SupplierCategorySelector.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, Folder, Plus, X } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { DEFAULT_SUPPLIER_CATEGORIES, normalizeSupplierCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
|
||||
|
||||
interface SupplierCategorySelectorProps {
|
||||
selectedCategories: string[]
|
||||
onChange: (categories: string[]) => void
|
||||
availableCategories?: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'zerp_supplier_custom_categories'
|
||||
|
||||
function readStoredCategories(): string[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredCategories(categories: string[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(categories))
|
||||
}
|
||||
|
||||
export default function SupplierCategorySelector({ selectedCategories, onChange, availableCategories = [] }: SupplierCategorySelectorProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [storedCategories, setStoredCategories] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setStoredCategories(readStoredCategories())
|
||||
}, [])
|
||||
|
||||
const options = useMemo(
|
||||
() => uniqueSupplierCategories([
|
||||
...DEFAULT_SUPPLIER_CATEGORIES,
|
||||
...availableCategories,
|
||||
...storedCategories,
|
||||
...selectedCategories,
|
||||
]),
|
||||
[availableCategories, selectedCategories, storedCategories],
|
||||
)
|
||||
|
||||
const filteredOptions = options.filter((category) =>
|
||||
normalizeSupplierCategoryName(category).includes(normalizeSupplierCategoryName(searchTerm)),
|
||||
)
|
||||
|
||||
const isSelected = (category: string) =>
|
||||
selectedCategories.some((selected) => normalizeSupplierCategoryName(selected) === normalizeSupplierCategoryName(category))
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
if (isSelected(category)) {
|
||||
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
|
||||
return
|
||||
}
|
||||
onChange(uniqueSupplierCategories([...selectedCategories, category]))
|
||||
}
|
||||
|
||||
const removeCategory = (category: string) => {
|
||||
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
|
||||
}
|
||||
|
||||
const addCategory = () => {
|
||||
const label = newCategoryName.trim()
|
||||
if (!label) {
|
||||
toast.error('اسم التصنيف مطلوب')
|
||||
return
|
||||
}
|
||||
|
||||
const nextStored = uniqueSupplierCategories([...storedCategories, label])
|
||||
setStoredCategories(nextStored)
|
||||
writeStoredCategories(nextStored)
|
||||
onChange(uniqueSupplierCategories([...selectedCategories, label]))
|
||||
setNewCategoryName('')
|
||||
setShowAddModal(false)
|
||||
toast.success('تمت إضافة التصنيف')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search categories..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowAddModal(true)} className="px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors" title="Add Category">
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
{selectedCategories.map((category) => (
|
||||
<span key={category} className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm">
|
||||
{category}
|
||||
<button type="button" onClick={() => removeCategory(category)} className="hover:text-emerald-900"><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>لا يوجد تصنيفات مطابقة</p>
|
||||
<button type="button" onClick={() => setShowAddModal(true)} className="mt-2 text-emerald-600 hover:text-emerald-700 text-sm">إضافة تصنيف جديد</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((category) => {
|
||||
const selected = isSelected(category)
|
||||
return (
|
||||
<div key={category} className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected ? 'bg-emerald-50 border border-emerald-200' : 'hover:bg-gray-50'}`} onClick={() => toggleCategory(category)}>
|
||||
<div className="w-6" />
|
||||
<Folder className="h-4 w-4 text-gray-600" />
|
||||
<span className="flex-1 text-sm font-medium text-gray-900 text-right">{category}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); toggleCategory(category) }}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors ${selected ? 'bg-emerald-600 border-emerald-600' : 'border-gray-300 bg-white hover:border-emerald-400'}`}
|
||||
>
|
||||
{selected && <Check className="h-3 w-3 text-white" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowAddModal(false)} />
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">إضافة تصنيف مورد</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">اسم التصنيف <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCategory() } }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900"
|
||||
placeholder="مثال: أجهزة طباعة"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<button type="button" onClick={() => { setShowAddModal(false); setNewCategoryName('') }} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">إلغاء</button>
|
||||
<button type="button" onClick={addCategory} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">إضافة</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ interface Permission {
|
||||
canDelete?: boolean
|
||||
canExport?: boolean
|
||||
canApprove?: boolean
|
||||
canMarkAsPaid?: boolean
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -37,7 +38,7 @@ interface AuthContextType {
|
||||
logout: () => void
|
||||
isLoading: 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)
|
||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
canDelete: wildcard || p.actions?.includes('delete') || false,
|
||||
canExport: wildcard || p.actions?.includes('export') || 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('/')
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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',
|
||||
delete: 'canDelete',
|
||||
export: 'canExport',
|
||||
approve: 'canApprove'
|
||||
approve: 'canApprove',
|
||||
'mark-as-paid': 'canMarkAsPaid'
|
||||
}
|
||||
|
||||
return permission[actionMap[action] as keyof Permission] as boolean
|
||||
|
||||
@@ -551,7 +551,7 @@ const translations = {
|
||||
view: 'عرض',
|
||||
win: 'فوز',
|
||||
lose: 'خسارة',
|
||||
archive: 'أرشفة',
|
||||
delete: 'حذف',
|
||||
deleteDeal: 'حذف الصفقة',
|
||||
markWon: 'تحديد كفائز',
|
||||
markLost: 'تحديد كخاسر',
|
||||
@@ -563,7 +563,7 @@ const translations = {
|
||||
updateSuccess: 'تم تحديث الصفقة بنجاح',
|
||||
winSuccess: 'تم الفوز بالصفقة بنجاح',
|
||||
loseSuccess: 'تم تحديد الصفقة كخاسرة',
|
||||
deleteSuccess: 'تم أرشفة الصفقة بنجاح',
|
||||
deleteSuccess: 'تم حذف الصفقة بنجاح',
|
||||
fixFormErrors: 'يرجى إصلاح أخطاء النموذج',
|
||||
pipelineRequired: 'مسار المبيعات مطلوب',
|
||||
dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',
|
||||
|
||||
@@ -81,6 +81,21 @@ export const dashboardAPI = {
|
||||
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 = {
|
||||
// Deals
|
||||
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface ContactFilters {
|
||||
rating?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
excludeSuppliers?: boolean
|
||||
}
|
||||
|
||||
export interface ContactsResponse {
|
||||
@@ -93,6 +94,7 @@ export const contactsAPI = {
|
||||
if (filters.rating) params.append('rating', filters.rating.toString())
|
||||
if (filters.page) params.append('page', filters.page.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 { data, pagination } = response.data
|
||||
@@ -154,6 +156,7 @@ export const contactsAPI = {
|
||||
if (filters.status) params.append('status', filters.status)
|
||||
if (filters.category) params.append('category', filters.category)
|
||||
if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
|
||||
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
|
||||
|
||||
const response = await api.get(`/contacts/export?${params.toString()}`, {
|
||||
responseType: 'blob'
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PortalProfile {
|
||||
activeLoansCount: number
|
||||
pendingLeavesCount: number
|
||||
pendingPurchaseRequestsCount: number
|
||||
pendingExpenseClaimsCount: number
|
||||
leaveBalance: Array<{
|
||||
leaveType: string
|
||||
totalDays: number
|
||||
@@ -98,6 +99,53 @@ export interface PurchaseRequest {
|
||||
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 {
|
||||
id: string
|
||||
date: string
|
||||
@@ -143,6 +191,17 @@ export interface Salary {
|
||||
|
||||
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[]> => {
|
||||
const q = new URLSearchParams()
|
||||
if (status && status !== 'all') q.append('status', status)
|
||||
@@ -175,6 +234,18 @@ export const portalAPI = {
|
||||
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[]> => {
|
||||
const response = await api.get('/hr/portal/overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -189,6 +260,18 @@ export const portalAPI = {
|
||||
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[]> => {
|
||||
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||
return response.data.data || []
|
||||
@@ -220,11 +303,39 @@ export const portalAPI = {
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => {
|
||||
const response = await api.post('/hr/portal/leaves', data)
|
||||
submitLeaveRequest: async (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
|
||||
},
|
||||
|
||||
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[]> => {
|
||||
const response = await api.get('/hr/portal/purchase-requests')
|
||||
return response.data.data || []
|
||||
@@ -235,6 +346,126 @@ export const portalAPI = {
|
||||
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[]> => {
|
||||
const params = new URLSearchParams()
|
||||
if (month) params.append('month', String(month))
|
||||
|
||||
123
frontend/src/lib/api/suppliers.ts
Normal file
123
frontend/src/lib/api/suppliers.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { api } from '../api'
|
||||
import { Contact } from './contacts'
|
||||
|
||||
export interface Supplier extends Contact {
|
||||
customFields?: {
|
||||
supplierCode?: string
|
||||
supplierCategory?: string
|
||||
supplierCategories?: string[]
|
||||
paymentTerms?: string
|
||||
bankName?: string
|
||||
bankAccount?: string
|
||||
contactPosition?: string
|
||||
notes?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface SupplierFilters {
|
||||
search?: string
|
||||
status?: string
|
||||
rating?: number
|
||||
category?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface SuppliersResponse {
|
||||
suppliers: Supplier[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface SupplierStats {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
blocked: number
|
||||
}
|
||||
|
||||
export interface CreateSupplierData {
|
||||
name: string
|
||||
nameAr?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
mobile?: string
|
||||
website?: string
|
||||
companyName?: string
|
||||
companyNameAr?: string
|
||||
taxNumber?: string
|
||||
commercialRegister?: string
|
||||
address?: string
|
||||
city?: string
|
||||
country?: string
|
||||
postalCode?: string
|
||||
categories?: string[]
|
||||
tags?: string[]
|
||||
source?: string
|
||||
rating?: number
|
||||
customFields?: Supplier['customFields']
|
||||
}
|
||||
|
||||
export interface UpdateSupplierData extends Partial<CreateSupplierData> {
|
||||
status?: string
|
||||
}
|
||||
|
||||
const buildParams = (filters: SupplierFilters = {}) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.search) params.append('search', filters.search)
|
||||
if (filters.status) params.append('status', filters.status)
|
||||
if (filters.rating) params.append('rating', filters.rating.toString())
|
||||
if (filters.category) params.append('category', filters.category)
|
||||
if (filters.page) params.append('page', filters.page.toString())
|
||||
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
|
||||
return params
|
||||
}
|
||||
|
||||
export const suppliersAPI = {
|
||||
getAll: async (filters: SupplierFilters = {}): Promise<SuppliersResponse> => {
|
||||
const params = buildParams(filters)
|
||||
const response = await api.get(`/suppliers?${params.toString()}`)
|
||||
const { data, pagination } = response.data
|
||||
return {
|
||||
suppliers: data || [],
|
||||
total: pagination?.total || 0,
|
||||
page: pagination?.page || 1,
|
||||
pageSize: pagination?.pageSize || 20,
|
||||
totalPages: pagination?.totalPages || 0,
|
||||
}
|
||||
},
|
||||
|
||||
getStats: async (): Promise<SupplierStats> => {
|
||||
const response = await api.get('/suppliers/stats')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Supplier> => {
|
||||
const response = await api.get(`/suppliers/${id}`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
create: async (data: CreateSupplierData): Promise<Supplier> => {
|
||||
const response = await api.post('/suppliers', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateSupplierData): Promise<Supplier> => {
|
||||
const response = await api.put(`/suppliers/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
archive: async (id: string, reason?: string): Promise<Supplier> => {
|
||||
const response = await api.post(`/suppliers/${id}/archive`, { reason })
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
export: async (filters: SupplierFilters = {}): Promise<Blob> => {
|
||||
const params = buildParams(filters)
|
||||
const response = await api.get(`/suppliers/export?${params.toString()}`, { responseType: 'blob' })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -115,17 +115,49 @@ export interface Project {
|
||||
name: string
|
||||
nameAr?: string
|
||||
description?: string
|
||||
type?: string
|
||||
status: string
|
||||
priority?: string
|
||||
progress?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
budget?: number
|
||||
estimatedCost?: number
|
||||
createdAt: 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 = {
|
||||
getAll: async (): Promise<Project[]> => {
|
||||
const response = await api.get('/projects/projects')
|
||||
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}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api'
|
||||
export interface Tender {
|
||||
id: string
|
||||
tenderNumber: string
|
||||
issueNumber?: string | null
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
@@ -57,6 +58,7 @@ export interface TenderDirective {
|
||||
|
||||
export interface CreateTenderData {
|
||||
tenderNumber: string
|
||||
issueNumber?: string
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
@@ -167,12 +169,16 @@ export const tendersAPI = {
|
||||
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> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (category) formData.append('category', category)
|
||||
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
|
||||
},
|
||||
@@ -182,7 +188,7 @@ export const tendersAPI = {
|
||||
formData.append('file', file)
|
||||
if (category) formData.append('category', category)
|
||||
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
|
||||
},
|
||||
@@ -205,4 +211,17 @@ export const tendersAPI = {
|
||||
const response = await api.get('/tenders/directive-type-values')
|
||||
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
|
||||
},
|
||||
}
|
||||
66
frontend/src/lib/supplierCategories.ts
Normal file
66
frontend/src/lib/supplierCategories.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Category } from './api/categories'
|
||||
|
||||
export const DEFAULT_SUPPLIER_CATEGORIES = [
|
||||
'كاميرات',
|
||||
'شبكات',
|
||||
'أجهزة كومبيوتر',
|
||||
'projectors',
|
||||
'مقاسم هاتفية',
|
||||
'Mobile - Tablet',
|
||||
'firewall',
|
||||
'طاقة بديلة',
|
||||
'حديد',
|
||||
'باركود - POS',
|
||||
'أجهزة منزلية',
|
||||
'تكييف وتبريد',
|
||||
]
|
||||
|
||||
const SUPPLIER_SYSTEM_NAMES = ['Supplier', 'Suppliers']
|
||||
const SUPPLIER_SYSTEM_AR_NAMES = ['مورد', 'مورّد', 'موردين']
|
||||
|
||||
export function normalizeSupplierCategoryName(value?: string | null) {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
export function isSupplierSystemCategoryName(name?: string | null, nameAr?: string | null) {
|
||||
const normalizedName = normalizeSupplierCategoryName(name)
|
||||
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
|
||||
|
||||
return (
|
||||
SUPPLIER_SYSTEM_NAMES.map(normalizeSupplierCategoryName).includes(normalizedName) ||
|
||||
SUPPLIER_SYSTEM_AR_NAMES.some((word) => normalizedNameAr.includes(normalizeSupplierCategoryName(word)))
|
||||
)
|
||||
}
|
||||
|
||||
export function isSupplierBusinessCategoryName(name?: string | null, nameAr?: string | null) {
|
||||
const normalizedName = normalizeSupplierCategoryName(name)
|
||||
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
|
||||
return DEFAULT_SUPPLIER_CATEGORIES.some((category) => {
|
||||
const normalizedCategory = normalizeSupplierCategoryName(category)
|
||||
return normalizedName === normalizedCategory || normalizedNameAr === normalizedCategory
|
||||
})
|
||||
}
|
||||
|
||||
export function isSupplierOnlyCategoryName(name?: string | null, nameAr?: string | null) {
|
||||
return isSupplierSystemCategoryName(name, nameAr) || isSupplierBusinessCategoryName(name, nameAr)
|
||||
}
|
||||
|
||||
export function filterContactCategoryTree(categories: Category[]): Category[] {
|
||||
return categories
|
||||
.filter((category) => !isSupplierOnlyCategoryName(category.name, category.nameAr))
|
||||
.map((category) => ({
|
||||
...category,
|
||||
children: category.children ? filterContactCategoryTree(category.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function uniqueSupplierCategories(values: Array<string | undefined | null>) {
|
||||
const map = new Map<string, string>()
|
||||
values.forEach((value) => {
|
||||
const label = String(value || '').trim()
|
||||
if (!label) return
|
||||
const key = normalizeSupplierCategoryName(label)
|
||||
if (!map.has(key)) map.set(key, label)
|
||||
})
|
||||
return Array.from(map.values())
|
||||
}
|
||||
Reference in New Issue
Block a user