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 built application
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Change ownership of all files to the nodejs user
|
# Ensure uploads directory exists and is owned by app user
|
||||||
RUN chown -R expressjs:nodejs /app
|
RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER expressjs
|
USER expressjs
|
||||||
|
|||||||
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[]
|
commissions Commission[]
|
||||||
loans Loan[]
|
loans Loan[]
|
||||||
purchaseRequests PurchaseRequest[]
|
purchaseRequests PurchaseRequest[]
|
||||||
|
expenseClaims ExpenseClaim[]
|
||||||
leaveEntitlements LeaveEntitlement[]
|
leaveEntitlements LeaveEntitlement[]
|
||||||
employeeContracts EmployeeContract[]
|
employeeContracts EmployeeContract[]
|
||||||
tenderDirectivesAssigned TenderDirective[]
|
tenderDirectivesAssigned TenderDirective[]
|
||||||
@@ -301,8 +302,8 @@ model Leave {
|
|||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id])
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc.
|
leaveType String // ANNUAL, SICK, UNPAID, EMERGENCY, etc.
|
||||||
startDate DateTime @db.Date
|
startDate DateTime @db.Timestamp(3)
|
||||||
endDate DateTime @db.Date
|
endDate DateTime @db.Timestamp(3)
|
||||||
days Int
|
days Int
|
||||||
reason String?
|
reason String?
|
||||||
status String @default("PENDING") // PENDING, APPROVED, REJECTED
|
status String @default("PENDING") // PENDING, APPROVED, REJECTED
|
||||||
@@ -503,6 +504,37 @@ model PurchaseRequest {
|
|||||||
@@map("purchase_requests")
|
@@map("purchase_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ExpenseClaim {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
|
||||||
|
claimNumber String @unique
|
||||||
|
|
||||||
|
items Json?
|
||||||
|
totalAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
|
||||||
|
expenseDate DateTime? @db.Date
|
||||||
|
amount Decimal? @db.Decimal(12, 2)
|
||||||
|
description String?
|
||||||
|
projectOrTender String?
|
||||||
|
|
||||||
|
status String @default("PENDING")
|
||||||
|
approvedBy String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
rejectedReason String?
|
||||||
|
approvalNote String?
|
||||||
|
|
||||||
|
isPaid Boolean @default(false)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([employeeId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("expense_claims")
|
||||||
|
}
|
||||||
|
|
||||||
model LeaveEntitlement {
|
model LeaveEntitlement {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
employeeId String
|
employeeId String
|
||||||
@@ -889,6 +921,7 @@ model Invoice {
|
|||||||
model Tender {
|
model Tender {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenderNumber String @unique
|
tenderNumber String @unique
|
||||||
|
issueNumber String?
|
||||||
issuingBodyName String
|
issuingBodyName String
|
||||||
title String
|
title String
|
||||||
termsValue Decimal @db.Decimal(15, 2)
|
termsValue Decimal @db.Decimal(15, 2)
|
||||||
@@ -911,6 +944,7 @@ model Tender {
|
|||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
convertedDeal Deal?
|
convertedDeal Deal?
|
||||||
@@index([tenderNumber])
|
@@index([tenderNumber])
|
||||||
|
@@index([issueNumber])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdById])
|
@@index([createdById])
|
||||||
@@index([announcementDate])
|
@@index([announcementDate])
|
||||||
@@ -1594,3 +1628,12 @@ model Approval {
|
|||||||
@@map("approvals")
|
@@map("approvals")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SystemSetting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
category String @default("general")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("system_settings")
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -33,10 +34,10 @@ export const config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
upload: {
|
upload: {
|
||||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10),
|
||||||
path: process.env.UPLOAD_PATH || './uploads',
|
path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'),
|
||||||
},
|
},
|
||||||
|
|
||||||
pagination: {
|
pagination: {
|
||||||
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
||||||
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class ContactsController {
|
|||||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||||
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined,
|
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined,
|
||||||
createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined,
|
createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined,
|
||||||
|
excludeSuppliers: req.query.excludeSuppliers === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await contactsService.findAll(filters, page, pageSize);
|
const result = await contactsService.findAll(filters, page, pageSize);
|
||||||
@@ -241,6 +242,7 @@ class ContactsController {
|
|||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||||
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
|
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
|
||||||
|
excludeSuppliers: req.query.excludeSuppliers === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = await contactsService.export(filters);
|
const buffer = await contactsService.export(filters);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ router.post(
|
|||||||
authorize('contacts', 'contacts', 'create'),
|
authorize('contacts', 'contacts', 'create'),
|
||||||
[
|
[
|
||||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
||||||
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION','SUPPLIER',]),
|
||||||
body('name').notEmpty().trim(),
|
body('name').notEmpty().trim(),
|
||||||
body('email').optional().isEmail(),
|
body('email').optional().isEmail(),
|
||||||
body('source').notEmpty(),
|
body('source').notEmpty(),
|
||||||
@@ -73,6 +73,7 @@ router.put(
|
|||||||
'UN',
|
'UN',
|
||||||
'NGO',
|
'NGO',
|
||||||
'INSTITUTION',
|
'INSTITUTION',
|
||||||
|
'SUPPLIER',
|
||||||
]),
|
]),
|
||||||
body('email')
|
body('email')
|
||||||
.optional({ values: 'falsy' })
|
.optional({ values: 'falsy' })
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface SearchFilters {
|
|||||||
createdFrom?: Date;
|
createdFrom?: Date;
|
||||||
createdTo?: Date;
|
createdTo?: Date;
|
||||||
excludeCompanyEmployees?: boolean;
|
excludeCompanyEmployees?: boolean;
|
||||||
|
excludeSuppliers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContactsService {
|
class ContactsService {
|
||||||
@@ -166,6 +167,22 @@ class ContactsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.excludeSuppliers) {
|
||||||
|
where.NOT = [
|
||||||
|
{ type: 'SUPPLIER' },
|
||||||
|
{
|
||||||
|
categories: {
|
||||||
|
some: {
|
||||||
|
OR: [
|
||||||
|
{ name: { in: ['Supplier', 'Suppliers'] } },
|
||||||
|
{ nameAr: { contains: 'مورد' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.createdFrom || filters.createdTo) {
|
if (filters.createdFrom || filters.createdTo) {
|
||||||
where.createdAt = {};
|
where.createdAt = {};
|
||||||
if (filters.createdFrom) {
|
if (filters.createdFrom) {
|
||||||
@@ -758,6 +775,7 @@ class ContactsService {
|
|||||||
const where: Prisma.ContactWhereInput = {
|
const where: Prisma.ContactWhereInput = {
|
||||||
status: { not: 'DELETED' },
|
status: { not: 'DELETED' },
|
||||||
};
|
};
|
||||||
|
const notConditions: Prisma.ContactWhereInput[] = [];
|
||||||
|
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
@@ -772,19 +790,39 @@ class ContactsService {
|
|||||||
if (filters.source) where.source = filters.source;
|
if (filters.source) where.source = filters.source;
|
||||||
if (filters.rating) where.rating = filters.rating;
|
if (filters.rating) where.rating = filters.rating;
|
||||||
|
|
||||||
|
if (filters.excludeSuppliers) {
|
||||||
|
notConditions.push(
|
||||||
|
{ type: 'SUPPLIER' },
|
||||||
|
{
|
||||||
|
categories: {
|
||||||
|
some: {
|
||||||
|
OR: [
|
||||||
|
{ name: { in: ['Supplier', 'Suppliers'] } },
|
||||||
|
{ nameAr: { contains: 'مورد' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.excludeCompanyEmployees) {
|
if (filters.excludeCompanyEmployees) {
|
||||||
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
|
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
|
||||||
where: { name: 'Company Employee', isActive: true },
|
where: { name: 'Company Employee', isActive: true },
|
||||||
});
|
});
|
||||||
if (companyEmployeeCategory) {
|
if (companyEmployeeCategory) {
|
||||||
where.NOT = {
|
notConditions.push({
|
||||||
categories: {
|
categories: {
|
||||||
some: { id: companyEmployeeCategory.id },
|
some: { id: companyEmployeeCategory.id },
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notConditions.length > 0) {
|
||||||
|
where.NOT = notConditions;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all contacts (no pagination for export)
|
// Fetch all contacts (no pagination for export)
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where,
|
where,
|
||||||
|
|||||||
@@ -2,8 +2,65 @@ import { Router } from 'express';
|
|||||||
import { hrController } from './hr.controller';
|
import { hrController } from './hr.controller';
|
||||||
import { portalController } from './portal.controller';
|
import { portalController } from './portal.controller';
|
||||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const expenseClaimsUploadDir = path.join(config.upload.path, 'expense-claims');
|
||||||
|
|
||||||
|
if (!fs.existsSync(expenseClaimsUploadDir)) {
|
||||||
|
fs.mkdirSync(expenseClaimsUploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseClaimStorage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||||
|
// but multer/busboy decode them as latin1 by default. For Arabic
|
||||||
|
// (or any non-ASCII) filenames this produces mojibake like "ÙÙŠÙØ§...".
|
||||||
|
// Reverse the misinterpretation: take the latin1 string back to bytes,
|
||||||
|
// then decode as UTF-8. The service reads `decodedOriginalName` when
|
||||||
|
// it persists the attachment to the DB.
|
||||||
|
try {
|
||||||
|
(file as any).decodedOriginalName = Buffer.from(
|
||||||
|
file.originalname,
|
||||||
|
'latin1'
|
||||||
|
).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
(file as any).decodedOriginalName = file.originalname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
|
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseClaimUpload = multer({
|
||||||
|
storage: expenseClaimStorage,
|
||||||
|
limits: { fileSize: config.upload.maxFileSize },
|
||||||
|
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.mimetype)) {
|
||||||
|
return cb(
|
||||||
|
new Error('نوع الملف غير مدعوم. يرجى رفع صورة أو ملف PDF.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
||||||
@@ -11,9 +68,13 @@ router.use(authenticate);
|
|||||||
router.get('/portal/me', portalController.getMe);
|
router.get('/portal/me', portalController.getMe);
|
||||||
router.get('/portal/loans', portalController.getMyLoans);
|
router.get('/portal/loans', portalController.getMyLoans);
|
||||||
router.post('/portal/loans', portalController.submitLoanRequest);
|
router.post('/portal/loans', portalController.submitLoanRequest);
|
||||||
|
router.put('/portal/loans/:id', portalController.updateMyLoan);
|
||||||
|
router.delete('/portal/loans/:id', portalController.deleteMyLoan);
|
||||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||||
|
router.put('/portal/leaves/:id', portalController.updateMyLeave);
|
||||||
|
router.delete('/portal/leaves/:id', portalController.deleteMyLeave);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/portal/managed-leaves',
|
'/portal/managed-leaves',
|
||||||
@@ -35,6 +96,8 @@ router.post(
|
|||||||
|
|
||||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||||
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
||||||
|
router.put('/portal/overtime-requests/:attendanceId', portalController.updateMyOvertimeRequest);
|
||||||
|
router.delete('/portal/overtime-requests/:attendanceId', portalController.deleteMyOvertimeRequest);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/portal/managed-overtime-requests',
|
'/portal/managed-overtime-requests',
|
||||||
@@ -56,9 +119,69 @@ router.post(
|
|||||||
|
|
||||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||||
|
router.put('/portal/purchase-requests/:id', portalController.updateMyPurchaseRequest);
|
||||||
|
router.delete('/portal/purchase-requests/:id', portalController.deleteMyPurchaseRequest);
|
||||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||||
router.get('/portal/salaries', portalController.getMySalaries);
|
router.get('/portal/salaries', portalController.getMySalaries);
|
||||||
|
|
||||||
|
router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
|
||||||
|
router.post(
|
||||||
|
'/portal/expense-claims',
|
||||||
|
(req, res, next) => {
|
||||||
|
// Accept up to 10 files under the form field "attachments".
|
||||||
|
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'تعذر رفع المرفقات',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
portalController.submitExpenseClaim
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
'/portal/expense-claims/:id',
|
||||||
|
(req, res, next) => {
|
||||||
|
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'تعذر رفع المرفقات',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
portalController.updateMyExpenseClaim
|
||||||
|
);
|
||||||
|
router.delete('/portal/expense-claims/:id', portalController.deleteMyExpenseClaim);
|
||||||
|
router.get(
|
||||||
|
'/portal/expense-claims/attachments/:attachmentId/view',
|
||||||
|
portalController.viewExpenseClaimAttachment
|
||||||
|
);
|
||||||
|
router.get('/portal/managed-expense-claims', authorize('department_expense_claims', '*', 'read'), portalController.getManagedExpenseClaims);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-expense-claims/:id/approve',
|
||||||
|
authorize('department_expense_claims', '*', 'approve'),
|
||||||
|
portalController.approveManagedExpenseClaim
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/portal/managed-expense-claims/:id/reject',
|
||||||
|
authorize('department_expense_claims', '*', 'approve'),
|
||||||
|
portalController.rejectManagedExpenseClaim
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/portal/managed-expense-claims/:id/paid',
|
||||||
|
authorize('department_expense_claims', '*', 'mark-as-paid'),
|
||||||
|
portalController.markExpenseClaimPaid
|
||||||
|
);
|
||||||
|
|
||||||
// ========== EMPLOYEES ==========
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||||
|
|||||||
@@ -313,54 +313,77 @@ class HRService {
|
|||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
async createLeaveRequest(data: any, userId: string) {
|
async createLeaveRequest(data: any, userId: string) {
|
||||||
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
|
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
|
||||||
|
|
||||||
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
|
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||||
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedLeaveType = String(data.leaveType).toUpperCase();
|
|
||||||
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
|
||||||
const startDate = new Date(data.startDate);
|
|
||||||
const year = startDate.getFullYear();
|
|
||||||
|
|
||||||
const ent = await prisma.leaveEntitlement.findUnique({
|
|
||||||
where: {
|
|
||||||
employeeId_year_leaveType: {
|
|
||||||
employeeId: data.employeeId,
|
|
||||||
year,
|
|
||||||
leaveType: normalizedLeaveType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ent) {
|
|
||||||
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
|
||||||
if (days > available) {
|
|
||||||
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(data.startDate);
|
||||||
|
const endDate = new Date(data.endDate);
|
||||||
|
|
||||||
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||||
|
throw new AppError(400, 'تاريخ أو وقت الإجازة غير صالح');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInvalidRange = normalizedLeaveType === 'HOURLY'
|
||||||
|
? endDate <= startDate
|
||||||
|
: endDate < startDate;
|
||||||
|
|
||||||
|
if (isInvalidRange) {
|
||||||
|
throw new AppError(400, 'وقت/تاريخ النهاية يجب أن يكون بعد البداية');
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = normalizedLeaveType === 'HOURLY'
|
||||||
|
? 0
|
||||||
|
: this.calculateLeaveDays(startDate, endDate);
|
||||||
|
|
||||||
|
const year = startDate.getFullYear();
|
||||||
|
|
||||||
|
if (normalizedLeaveType !== 'HOURLY') {
|
||||||
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ent) {
|
||||||
|
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||||
|
if (days > available) {
|
||||||
|
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await prisma.leave.create({
|
||||||
|
data: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
days,
|
||||||
|
reason: data.reason || undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: leave.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leave = await prisma.leave.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
leaveType: normalizedLeaveType,
|
|
||||||
days,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
employee: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await AuditLogger.log({
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: leave.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return leave;
|
|
||||||
}
|
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -144,19 +144,65 @@ export class PortalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const data = {
|
const body = { ...req.body };
|
||||||
...req.body,
|
const leaveType = String(body.leaveType || '').toUpperCase();
|
||||||
startDate: new Date(req.body.startDate),
|
|
||||||
endDate: new Date(req.body.endDate),
|
let startDate: Date;
|
||||||
};
|
let endDate: Date;
|
||||||
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
|
|
||||||
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
|
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
|
||||||
} catch (error) {
|
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
|
||||||
next(error);
|
endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
|
||||||
|
} else {
|
||||||
|
startDate = new Date(body.startDate);
|
||||||
|
endDate = new Date(body.endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'تاريخ أو وقت الإجازة غير صالح - Invalid leave date or time',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInvalidRange = leaveType === 'HOURLY'
|
||||||
|
? endDate <= startDate
|
||||||
|
: endDate < startDate;
|
||||||
|
|
||||||
|
if (isInvalidRange) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'وقت/تاريخ النهاية يجب أن يكون بعد البداية - End date/time must be after start date/time',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
leaveType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
reason: body.reason || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const leave = await portalService.submitLeaveRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
data,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(201)
|
||||||
|
.json(
|
||||||
|
ResponseFormatter.success(
|
||||||
|
leave,
|
||||||
|
'تم إرسال طلب الإجازة - Leave request submitted'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +222,146 @@ export class PortalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await portalService.getMyExpenseClaims(req.user?.employeeId);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const body = { ...req.body };
|
||||||
|
|
||||||
|
if (typeof body.items === 'string') {
|
||||||
|
body.items = JSON.parse(body.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = (req.files as Express.Multer.File[] | undefined) || [];
|
||||||
|
|
||||||
|
const data = await portalService.submitExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
body,
|
||||||
|
req.user!.id,
|
||||||
|
files
|
||||||
|
);
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(201)
|
||||||
|
.json(
|
||||||
|
ResponseFormatter.success(
|
||||||
|
data,
|
||||||
|
'تم إرسال كشف المصاريف - Expense claim submitted'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('نوع الملف غير مدعوم')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async viewExpenseClaimAttachment(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const attachment = await portalService.getExpenseClaimAttachmentFile(
|
||||||
|
req.params.attachmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedFileName = encodeURIComponent(attachment.originalName);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', attachment.mimeType);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename*=UTF-8''${encodedFileName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.sendFile(attachment.path);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const paid = req.query.paid as string | undefined;
|
||||||
|
const data = await portalService.getManagedExpenseClaims(
|
||||||
|
req.user?.employeeId,
|
||||||
|
status,
|
||||||
|
search,
|
||||||
|
paid,
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { approvalNote } = req.body;
|
||||||
|
|
||||||
|
const data = await portalService.approveManagedExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.user!.id,
|
||||||
|
approvalNote
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(ResponseFormatter.success(data, 'تمت الموافقة على كشف المصاريف - Expense claim approved'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const data = await portalService.rejectManagedExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
rejectedReason || '',
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(data, 'تم رفض كشف المصاريف - Expense claim rejected'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markExpenseClaimPaid(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const isPaid = Boolean(req.body?.isPaid);
|
||||||
|
const data = await portalService.markExpenseClaimPaid(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
isPaid,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(
|
||||||
|
ResponseFormatter.success(
|
||||||
|
data,
|
||||||
|
isPaid
|
||||||
|
? 'تم تعليم كشف المصاريف كمقبوض - Expense claim marked as paid'
|
||||||
|
: 'تم إلغاء تعليم القبض - Paid mark removed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||||
@@ -195,6 +381,178 @@ export class PortalController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== PERSONAL EDIT/DELETE (pending only) ==========
|
||||||
|
|
||||||
|
async updateMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const body = { ...req.body };
|
||||||
|
const leaveType = body.leaveType ? String(body.leaveType).toUpperCase() : undefined;
|
||||||
|
|
||||||
|
let startDate: Date | undefined;
|
||||||
|
let endDate: Date | undefined;
|
||||||
|
|
||||||
|
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
|
||||||
|
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
|
||||||
|
endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`);
|
||||||
|
} else if (body.startDate || body.endDate) {
|
||||||
|
startDate = body.startDate ? new Date(body.startDate) : undefined;
|
||||||
|
endDate = body.endDate ? new Date(body.endDate) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await portalService.updateMyLeave(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
{
|
||||||
|
leaveType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
reason: body.reason,
|
||||||
|
},
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الإجازة'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.deleteMyLeave(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم حذف طلب الإجازة'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.updateMyPurchaseRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.body,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الشراء'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.deleteMyPurchaseRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم حذف طلب الشراء'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.updateMyLoan(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.body,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم تعديل طلب القرض'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.deleteMyLoan(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم حذف طلب القرض'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.updateMyOvertimeRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.attendanceId,
|
||||||
|
{ hours: req.body.hours, reason: req.body.reason },
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم تعديل طلب الساعات الإضافية'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.deleteMyOvertimeRequest(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.attendanceId,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم حذف طلب الساعات الإضافية'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const body = { ...req.body };
|
||||||
|
if (typeof body.items === 'string') {
|
||||||
|
body.items = JSON.parse(body.items);
|
||||||
|
}
|
||||||
|
if (typeof body.removeAttachmentIds === 'string') {
|
||||||
|
try {
|
||||||
|
body.removeAttachmentIds = JSON.parse(body.removeAttachmentIds);
|
||||||
|
} catch {
|
||||||
|
body.removeAttachmentIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = (req.files as Express.Multer.File[] | undefined) || [];
|
||||||
|
const data = await portalService.updateMyExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
body,
|
||||||
|
req.user!.id,
|
||||||
|
files
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(data, 'تم تعديل كشف المصاريف'));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('نوع الملف غير مدعوم')) {
|
||||||
|
return res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const result = await portalService.deleteMyExpenseClaim(
|
||||||
|
req.user?.employeeId,
|
||||||
|
req.params.id,
|
||||||
|
req.user!.id
|
||||||
|
);
|
||||||
|
res.json(ResponseFormatter.success(result, 'تم حذف كشف المصاريف'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const portalController = new PortalController();
|
export const portalController = new PortalController();
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database';
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
import { hrService } from './hr.service';
|
import { hrService } from './hr.service';
|
||||||
|
import { notificationsService } from '../notifications/notifications.service';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
|
||||||
|
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
|
||||||
|
// byte in 0xC2-0xDF followed by a continuation byte in 0x80-0xBF, which is
|
||||||
|
// exactly what we look for here. Properly-stored Arabic text has code
|
||||||
|
// points U+0600-U+06FF and won't match this pattern, so the check is safe.
|
||||||
|
const MOJIBAKE_PATTERN = /[\u00C2-\u00DF][\u0080-\u00BF]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristically repair a string that was stored after multer interpreted
|
||||||
|
* the file's UTF-8 filename bytes as latin1. Returns the input unchanged
|
||||||
|
* if it doesn't look like mojibake or if re-decoding would lose data.
|
||||||
|
*/
|
||||||
|
function repairFilenameEncoding(value: string | null | undefined): string {
|
||||||
|
if (!value) return value ?? '';
|
||||||
|
if (!MOJIBAKE_PATTERN.test(value)) return value;
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(value, 'latin1').toString('utf8');
|
||||||
|
// If the re-decode produced replacement chars, the original wasn't
|
||||||
|
// actually mojibake — bail out and keep the existing value.
|
||||||
|
if (decoded.includes('\uFFFD')) return value;
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PortalService {
|
class PortalService {
|
||||||
private requireEmployeeId(employeeId: string | undefined): string {
|
private requireEmployeeId(employeeId: string | undefined): string {
|
||||||
@@ -31,10 +60,11 @@ class PortalService {
|
|||||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
const [loansCount, pendingLeaves, pendingPurchaseRequests, pendingExpenseClaims, leaveBalance] = await Promise.all([
|
||||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||||
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
|
prisma.expenseClaim.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||||
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -44,6 +74,7 @@ class PortalService {
|
|||||||
activeLoansCount: loansCount,
|
activeLoansCount: loansCount,
|
||||||
pendingLeavesCount: pendingLeaves,
|
pendingLeavesCount: pendingLeaves,
|
||||||
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
||||||
|
pendingExpenseClaimsCount: pendingExpenseClaims,
|
||||||
leaveBalance,
|
leaveBalance,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -212,9 +243,78 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const employeeFullName = `${attendance.employee.firstName} ${attendance.employee.lastName}`;
|
||||||
|
|
||||||
|
await notificationsService.notifyApprovalRecipients({
|
||||||
|
resource: 'overtime_requests',
|
||||||
|
fallbackEmployeeId: attendance.employeeId,
|
||||||
|
fallbackToManager: true,
|
||||||
|
type: 'OVERTIME_REQUEST_SUBMITTED',
|
||||||
|
title: 'طلب ساعات إضافية جديد بانتظار الموافقة',
|
||||||
|
message: `قام الموظف ${employeeFullName} بإرسال طلب ساعات إضافية جديد.`,
|
||||||
|
entityType: 'OVERTIME_REQUEST',
|
||||||
|
entityId: attendance.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: attendance.employeeId,
|
||||||
|
type: 'OVERTIME_REQUEST_CREATED',
|
||||||
|
title: 'تم إرسال طلب الساعات الإضافية',
|
||||||
|
message: 'تم إرسال طلب الساعات الإضافية الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
|
||||||
|
entityType: 'OVERTIME_REQUEST',
|
||||||
|
entityId: attendance.id,
|
||||||
|
excludeUserIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
return this.formatOvertimeRequest(attendance);
|
return this.formatOvertimeRequest(attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async attachExpenseClaimFiles<T extends { id: string }>(claims: T[]) {
|
||||||
|
const claimIds = claims.map((claim) => claim.id);
|
||||||
|
|
||||||
|
if (claimIds.length === 0) {
|
||||||
|
return claims.map((claim) => ({ ...claim, attachments: [] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
where: {
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: { in: claimIds },
|
||||||
|
},
|
||||||
|
orderBy: { uploadedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return claims.map((claim) => ({
|
||||||
|
...claim,
|
||||||
|
attachments: attachments
|
||||||
|
.filter((attachment) => attachment.entityId === claim.id)
|
||||||
|
.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
// Repair mojibake in records uploaded before the multer fix.
|
||||||
|
originalName: repairFilenameEncoding(attachment.originalName),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpenseClaimAttachmentFile(attachmentId: string) {
|
||||||
|
const attachment = await prisma.attachment.findUnique({
|
||||||
|
where: { id: attachmentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment || attachment.entityType !== 'EXPENSE_CLAIM') {
|
||||||
|
throw new AppError(404, 'الملف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair mojibake so the Content-Disposition filename* the controller
|
||||||
|
// generates uses the real Arabic name when opening/downloading.
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
originalName: repairFilenameEncoding(attachment.originalName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||||
this.requireEmployeeId(employeeId);
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
@@ -302,6 +402,16 @@ class PortalService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: updated.employeeId,
|
||||||
|
type: 'OVERTIME_REQUEST_APPROVED',
|
||||||
|
title: 'تمت الموافقة على طلب الساعات الإضافية',
|
||||||
|
message: 'تمت الموافقة على طلب الساعات الإضافية الخاص بك.',
|
||||||
|
entityType: 'OVERTIME_REQUEST',
|
||||||
|
entityId: updated.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return this.formatOvertimeRequest(updated);
|
return this.formatOvertimeRequest(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +481,18 @@ class PortalService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: updated.employeeId,
|
||||||
|
type: 'OVERTIME_REQUEST_REJECTED',
|
||||||
|
title: 'تم رفض طلب الساعات الإضافية',
|
||||||
|
message: rejectedReason?.trim()
|
||||||
|
? `تم رفض طلب الساعات الإضافية الخاص بك. السبب: ${rejectedReason.trim()}`
|
||||||
|
: 'تم رفض طلب الساعات الإضافية الخاص بك.',
|
||||||
|
entityType: 'OVERTIME_REQUEST',
|
||||||
|
entityId: updated.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
return this.formatOvertimeRequest(updated);
|
return this.formatOvertimeRequest(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +567,406 @@ class PortalService {
|
|||||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async generateExpenseClaimNumber(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `EC-${year}-`;
|
||||||
|
|
||||||
|
const last = await prisma.expenseClaim.findFirst({
|
||||||
|
where: {
|
||||||
|
claimNumber: {
|
||||||
|
startsWith: prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
claimNumber: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let next = 1;
|
||||||
|
if (last?.claimNumber) {
|
||||||
|
const parts = last.claimNumber.split('-');
|
||||||
|
next = parseInt(parts[2] || '0', 10) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyExpenseClaims(employeeId: string | undefined) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const claims = await prisma.expenseClaim.findMany({
|
||||||
|
where: { employeeId: empId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.attachExpenseClaimFiles(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitExpenseClaim(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: {
|
||||||
|
items?: Array<{
|
||||||
|
expenseDate?: string;
|
||||||
|
amount?: number | string;
|
||||||
|
entityName?: string;
|
||||||
|
description?: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
userId: string,
|
||||||
|
files?: Express.Multer.File[]
|
||||||
|
) {
|
||||||
|
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
|
||||||
|
const normalizedItems = items
|
||||||
|
.map((item) => ({
|
||||||
|
expenseDate: item.expenseDate || '',
|
||||||
|
amount: Number(item.amount || 0),
|
||||||
|
entityName: item.entityName?.trim() || '',
|
||||||
|
description: item.description?.trim() || '',
|
||||||
|
projectOrTender: item.projectOrTender?.trim() || '',
|
||||||
|
proofRef: item.proofRef?.trim() || '',
|
||||||
|
}))
|
||||||
|
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
|
||||||
|
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimNumber = await this.generateExpenseClaimNumber();
|
||||||
|
|
||||||
|
const totalAmount = normalizedItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
|
||||||
|
const firstItem = normalizedItems[0];
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.create({
|
||||||
|
data: {
|
||||||
|
claimNumber,
|
||||||
|
employeeId: empId,
|
||||||
|
items: normalizedItems as any,
|
||||||
|
totalAmount,
|
||||||
|
expenseDate: new Date(firstItem.expenseDate),
|
||||||
|
amount: totalAmount,
|
||||||
|
description: data.description?.trim() || null,
|
||||||
|
projectOrTender: firstItem.projectOrTender || null,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file) =>
|
||||||
|
prisma.attachment.create({
|
||||||
|
data: {
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
fileName: path.basename(file.path),
|
||||||
|
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
path: file.path,
|
||||||
|
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||||
|
uploadedBy: userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
|
||||||
|
|
||||||
|
await notificationsService.notifyUsersWithPermission({
|
||||||
|
module: 'department_expense_claims',
|
||||||
|
resource: '*',
|
||||||
|
action: 'approve',
|
||||||
|
type: 'EXPENSE_CLAIM_SUBMITTED',
|
||||||
|
title: 'كشف مصاريف جديد بانتظار الموافقة',
|
||||||
|
message: `قام الموظف ${employeeFullName} بإرسال كشف مصاريف جديد برقم ${claim.claimNumber}.`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_CREATED',
|
||||||
|
title: 'تم إرسال كشف المصاريف',
|
||||||
|
message: `تم إرسال كشف المصاريف الخاص بك برقم ${claim.claimNumber} وهو الآن بانتظار المراجعة.`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]);
|
||||||
|
return claimWithAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManagedExpenseClaims(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
status?: string,
|
||||||
|
search?: string,
|
||||||
|
paid?: string,
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(employeeId);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paid === 'paid') {
|
||||||
|
where.isPaid = true;
|
||||||
|
} else if (paid === 'unpaid') {
|
||||||
|
where.isPaid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSearch = search?.trim();
|
||||||
|
if (trimmedSearch) {
|
||||||
|
where.employee = {
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ firstNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ lastNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ uniqueEmployeeId: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await prisma.expenseClaim.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.attachExpenseClaimFiles(claims);
|
||||||
|
}
|
||||||
|
async approveManagedExpenseClaim(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
userId: string,
|
||||||
|
approvalNote?: string,
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
approvedBy: userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
|
approvalNote: approvalNote?.trim()?.slice(0, 1000) || null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const note = approvalNote?.trim()?.slice(0, 1000);
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_APPROVED',
|
||||||
|
title: 'تمت الموافقة على كشف المصاريف',
|
||||||
|
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.${note ? ` ملاحظة المعتمِد: ${note}` : ''}`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectManagedExpenseClaim(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
rejectedReason: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب الرفض مطلوب');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason: rejectedReason.trim(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationsService.notifyEmployeeUser({
|
||||||
|
employeeId: claim.employeeId,
|
||||||
|
type: 'EXPENSE_CLAIM_REJECTED',
|
||||||
|
title: 'تم رفض كشف المصاريف',
|
||||||
|
message: `تم رفض كشف المصاريف الخاص بك برقم ${claim.claimNumber}. السبب: ${rejectedReason.trim()}`,
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claim.id,
|
||||||
|
excludeUserIds: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markExpenseClaimPaid(
|
||||||
|
managerEmployeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
isPaid: boolean,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
this.requireEmployeeId(managerEmployeeId);
|
||||||
|
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
select: { id: true, status: true, isPaid: true, claimNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'كشف المصاريف غير موجود - Expense claim not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status !== 'APPROVED') {
|
||||||
|
throw new AppError(
|
||||||
|
400,
|
||||||
|
'يمكن تعليم القبض فقط على الكشوف المعتمدة - Only approved claims can be marked as paid'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.isPaid === isPaid) {
|
||||||
|
// Idempotent: no change needed.
|
||||||
|
const claim = await prisma.expenseClaim.findUnique({
|
||||||
|
where: { id: claimId },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: { isPaid },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
}
|
||||||
|
|
||||||
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -461,6 +983,376 @@ class PortalService {
|
|||||||
take: 24,
|
take: 24,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== PERSONAL PORTAL EDIT/DELETE (PENDING-only) ==========
|
||||||
|
// These actions are restricted to the request owner and only while the
|
||||||
|
// request is still in its initial pending state.
|
||||||
|
|
||||||
|
// ---------- Leaves ----------
|
||||||
|
async updateMyLeave(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
leaveId: string,
|
||||||
|
data: { leaveType?: string; startDate?: Date; endDate?: Date; reason?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
|
||||||
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
|
||||||
|
if (leave.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete and re-create through the normal validated path so we
|
||||||
|
// benefit from leave-balance checks and audit logging.
|
||||||
|
await prisma.leave.delete({ where: { id: leaveId } });
|
||||||
|
|
||||||
|
return hrService.createLeaveRequest(
|
||||||
|
{
|
||||||
|
employeeId: empId,
|
||||||
|
leaveType: data.leaveType ?? leave.leaveType,
|
||||||
|
startDate: data.startDate ?? leave.startDate,
|
||||||
|
endDate: data.endDate ?? leave.endDate,
|
||||||
|
reason: data.reason !== undefined ? data.reason : leave.reason || undefined,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyLeave(employeeId: string | undefined, leaveId: string, _userId: string) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const leave = await prisma.leave.findUnique({ where: { id: leaveId } });
|
||||||
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود');
|
||||||
|
if (leave.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
await prisma.leave.delete({ where: { id: leaveId } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Purchase requests ----------
|
||||||
|
async updateMyPurchaseRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
requestId: string,
|
||||||
|
data: { items?: any[]; reason?: string; priority?: string },
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
|
||||||
|
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(data.items) ? data.items : (existing.items as any[]) || [];
|
||||||
|
const totalAmount = items.reduce(
|
||||||
|
(s: number, i: any) =>
|
||||||
|
s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.purchaseRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
totalAmount,
|
||||||
|
reason: data.reason !== undefined ? data.reason : existing.reason,
|
||||||
|
priority: data.priority ?? existing.priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyPurchaseRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
requestId: string,
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.purchaseRequest.findUnique({ where: { id: requestId } });
|
||||||
|
if (!existing) throw new AppError(404, 'طلب الشراء غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
await prisma.purchaseRequest.delete({ where: { id: requestId } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Loans ----------
|
||||||
|
async updateMyLoan(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
loanId: string,
|
||||||
|
data: { type?: string; amount?: number; installments?: number; reason?: string },
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
|
||||||
|
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING_HR') {
|
||||||
|
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = data.amount !== undefined ? Number(data.amount) : Number(existing.amount);
|
||||||
|
const installments =
|
||||||
|
data.installments !== undefined ? Number(data.installments) : existing.installments;
|
||||||
|
const monthlyAmount = installments > 0 ? amount / installments : amount;
|
||||||
|
|
||||||
|
return prisma.loan.update({
|
||||||
|
where: { id: loanId },
|
||||||
|
data: {
|
||||||
|
type: data.type ?? existing.type,
|
||||||
|
amount,
|
||||||
|
installments,
|
||||||
|
monthlyAmount,
|
||||||
|
reason: data.reason !== undefined ? data.reason : existing.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyLoan(employeeId: string | undefined, loanId: string, _userId: string) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.loan.findUnique({ where: { id: loanId } });
|
||||||
|
if (!existing) throw new AppError(404, 'طلب القرض غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING_HR') {
|
||||||
|
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
await prisma.loan.delete({ where: { id: loanId } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Overtime requests (stored as attendance rows) ----------
|
||||||
|
async updateMyOvertimeRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
attendanceId: string,
|
||||||
|
data: { hours?: number; reason?: string },
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
|
||||||
|
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||||
|
if (att.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||||
|
}
|
||||||
|
const parsed = this.parseOvertimeRequestNote(att.notes);
|
||||||
|
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||||
|
if (parsed.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = data.hours !== undefined ? Number(data.hours) : parsed.hours;
|
||||||
|
const reason = data.reason !== undefined ? data.reason : parsed.reason;
|
||||||
|
|
||||||
|
if (!hours || hours <= 0) throw new AppError(400, 'عدد الساعات غير صالح');
|
||||||
|
if (!reason || !String(reason).trim()) throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
|
||||||
|
|
||||||
|
const updatedNote = this.buildOvertimeRequestNote(hours, String(reason).trim(), 'PENDING');
|
||||||
|
const updated = await prisma.attendance.update({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
data: { overtimeHours: hours, notes: updatedNote },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.formatOvertimeRequest(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyOvertimeRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
attendanceId: string,
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const att = await prisma.attendance.findUnique({ where: { id: attendanceId } });
|
||||||
|
if (!att) throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
|
||||||
|
if (att.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||||
|
}
|
||||||
|
const parsed = this.parseOvertimeRequestNote(att.notes);
|
||||||
|
if (!parsed) throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
|
||||||
|
if (parsed.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
// Clear the overtime request markers but keep the attendance row intact.
|
||||||
|
await prisma.attendance.update({
|
||||||
|
where: { id: attendanceId },
|
||||||
|
data: { overtimeHours: 0, notes: null },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Expense claims ----------
|
||||||
|
async updateMyExpenseClaim(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
data: {
|
||||||
|
items?: Array<{
|
||||||
|
expenseDate?: string;
|
||||||
|
amount?: number | string;
|
||||||
|
entityName?: string;
|
||||||
|
description?: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
removeAttachmentIds?: string[];
|
||||||
|
},
|
||||||
|
userId: string,
|
||||||
|
newFiles?: Express.Multer.File[]
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
|
||||||
|
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك تعديل طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن تعديل الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
const normalizedItems = items
|
||||||
|
.map((item) => ({
|
||||||
|
expenseDate: item.expenseDate || '',
|
||||||
|
amount: Number(item.amount || 0),
|
||||||
|
entityName: item.entityName?.trim() || '',
|
||||||
|
description: item.description?.trim() || '',
|
||||||
|
projectOrTender: item.projectOrTender?.trim() || '',
|
||||||
|
proofRef: item.proofRef?.trim() || '',
|
||||||
|
}))
|
||||||
|
.filter((item) => item.description && item.amount > 0 && item.expenseDate);
|
||||||
|
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
throw new AppError(400, 'يجب إضافة بند واحد على الأقل مع التاريخ والمبلغ والبيان');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = normalizedItems.reduce(
|
||||||
|
(sum, item) => sum + Number(item.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const firstItem = normalizedItems[0];
|
||||||
|
|
||||||
|
// Remove selected attachments (DB + file on disk).
|
||||||
|
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: data.removeAttachmentIds },
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claimId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const a of attachments) {
|
||||||
|
try {
|
||||||
|
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
|
||||||
|
} catch {
|
||||||
|
/* swallow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.attachment.deleteMany({
|
||||||
|
where: { id: { in: data.removeAttachmentIds }, entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.expenseClaim.update({
|
||||||
|
where: { id: claimId },
|
||||||
|
data: {
|
||||||
|
items: normalizedItems as any,
|
||||||
|
totalAmount,
|
||||||
|
expenseDate: new Date(firstItem.expenseDate),
|
||||||
|
amount: totalAmount,
|
||||||
|
description: data.description?.trim() || null,
|
||||||
|
projectOrTender: firstItem.projectOrTender || null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newFiles && newFiles.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
newFiles.map((file) =>
|
||||||
|
prisma.attachment.create({
|
||||||
|
data: {
|
||||||
|
entityType: 'EXPENSE_CLAIM',
|
||||||
|
entityId: claimId,
|
||||||
|
fileName: path.basename(file.path),
|
||||||
|
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
path: file.path,
|
||||||
|
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||||
|
uploadedBy: userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [withFiles] = await this.attachExpenseClaimFiles([updated]);
|
||||||
|
return withFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyExpenseClaim(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
claimId: string,
|
||||||
|
_userId: string
|
||||||
|
) {
|
||||||
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
|
const existing = await prisma.expenseClaim.findUnique({ where: { id: claimId } });
|
||||||
|
if (!existing) throw new AppError(404, 'كشف المصاريف غير موجود');
|
||||||
|
if (existing.employeeId !== empId) {
|
||||||
|
throw new AppError(403, 'لا يمكنك حذف طلب لا يخصك');
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن حذف الطلب بعد مراجعته');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||||
|
});
|
||||||
|
for (const a of attachments) {
|
||||||
|
try {
|
||||||
|
if (a.path && fs.existsSync(a.path)) fs.unlinkSync(a.path);
|
||||||
|
} catch {
|
||||||
|
/* swallow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.attachment.deleteMany({
|
||||||
|
where: { entityType: 'EXPENSE_CLAIM', entityId: claimId },
|
||||||
|
});
|
||||||
|
await prisma.expenseClaim.delete({ where: { id: claimId } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const portalService = new PortalService();
|
export const portalService = new PortalService();
|
||||||
@@ -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 prisma from '../../config/database';
|
||||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
|
import { AppError } from '../../shared/middleware/errorHandler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Convert a "YYYY-MM-DD" or ISO string to a Date at UTC midnight.
|
||||||
|
// Prisma's @db.Date columns require a real DateTime; a bare "YYYY-MM-DD"
|
||||||
|
// in JSON gets parsed to "Invalid data" by the validator.
|
||||||
|
const toDate = (value: unknown): Date | null | undefined => {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (value === undefined || value === '') return undefined;
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
// Already ISO?
|
||||||
|
if (value.includes('T')) {
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
// Plain "YYYY-MM-DD"
|
||||||
|
const d = new Date(`${value}T00:00:00Z`);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop empty strings, normalize known date/number fields, and only keep keys
|
||||||
|
// that actually exist on the Prisma Task model. Anything extra (e.g. `tags`)
|
||||||
|
// would otherwise crash Prisma with "Unknown argument".
|
||||||
|
const sanitizeTaskBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
|
||||||
|
const out: any = {};
|
||||||
|
|
||||||
|
const setIfPresent = (key: string, value: any) => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
out[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strings: collapse '' -> null (update) or skip (create)
|
||||||
|
const strField = (key: string) => {
|
||||||
|
if (!(key in body)) return;
|
||||||
|
const v = body[key];
|
||||||
|
if (v === '' || v === null) {
|
||||||
|
if (opts.isUpdate) out[key] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') out[key] = v.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
strField('title');
|
||||||
|
strField('description');
|
||||||
|
strField('projectId');
|
||||||
|
strField('phaseId');
|
||||||
|
strField('parentId');
|
||||||
|
strField('assignedToId');
|
||||||
|
|
||||||
|
if ('status' in body && body.status) out.status = String(body.status);
|
||||||
|
if ('priority' in body && body.priority) out.priority = String(body.priority);
|
||||||
|
|
||||||
|
if ('progress' in body) {
|
||||||
|
const n = Number(body.progress);
|
||||||
|
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('startDate' in body) {
|
||||||
|
const d = toDate(body.startDate);
|
||||||
|
if (d !== undefined) out.startDate = d;
|
||||||
|
}
|
||||||
|
if ('dueDate' in body) {
|
||||||
|
const d = toDate(body.dueDate);
|
||||||
|
if (d !== undefined) out.dueDate = d;
|
||||||
|
}
|
||||||
|
if ('completedDate' in body) {
|
||||||
|
const d = toDate(body.completedDate);
|
||||||
|
if (d !== undefined) out.completedDate = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('estimatedHours' in body) {
|
||||||
|
const v = body.estimatedHours;
|
||||||
|
if (v === '' || v === null) {
|
||||||
|
if (opts.isUpdate) out.estimatedHours = null;
|
||||||
|
} else {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isNaN(n) && n >= 0) out.estimatedHours = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('actualHours' in body) {
|
||||||
|
const v = body.actualHours;
|
||||||
|
if (v === '' || v === null) {
|
||||||
|
if (opts.isUpdate) out.actualHours = null;
|
||||||
|
} else {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isNaN(n) && n >= 0) out.actualHours = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dependencies is Json? in the schema
|
||||||
|
if ('dependencies' in body) out.dependencies = body.dependencies;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeProjectBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
|
||||||
|
const out: any = {};
|
||||||
|
|
||||||
|
const strField = (key: string) => {
|
||||||
|
if (!(key in body)) return;
|
||||||
|
const v = body[key];
|
||||||
|
if (v === '' || v === null) {
|
||||||
|
if (opts.isUpdate) out[key] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') out[key] = v.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Required-ish strings
|
||||||
|
if ('name' in body && body.name) out.name = String(body.name).trim();
|
||||||
|
if ('type' in body && body.type) out.type = String(body.type);
|
||||||
|
strField('description');
|
||||||
|
strField('dealId');
|
||||||
|
strField('clientId');
|
||||||
|
|
||||||
|
if ('status' in body && body.status) out.status = String(body.status);
|
||||||
|
if ('priority' in body && body.priority) out.priority = String(body.priority);
|
||||||
|
|
||||||
|
if ('progress' in body) {
|
||||||
|
const n = Number(body.progress);
|
||||||
|
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('startDate' in body) {
|
||||||
|
const d = toDate(body.startDate);
|
||||||
|
if (d !== undefined) out.startDate = d;
|
||||||
|
}
|
||||||
|
if ('endDate' in body) {
|
||||||
|
const d = toDate(body.endDate);
|
||||||
|
if (d !== undefined) out.endDate = d;
|
||||||
|
}
|
||||||
|
if ('actualEndDate' in body) {
|
||||||
|
const d = toDate(body.actualEndDate);
|
||||||
|
if (d !== undefined) out.actualEndDate = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numField = (key: string) => {
|
||||||
|
if (!(key in body)) return;
|
||||||
|
const v = body[key];
|
||||||
|
if (v === '' || v === null) {
|
||||||
|
if (opts.isUpdate) out[key] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isNaN(n)) out[key] = n;
|
||||||
|
};
|
||||||
|
numField('estimatedCost');
|
||||||
|
numField('actualCost');
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
// Projects
|
// Projects
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
@@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
|||||||
notes: true,
|
notes: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||||
|
}
|
||||||
res.json(ResponseFormatter.success(project));
|
res.json(ResponseFormatter.success(project));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -45,18 +206,30 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
|||||||
|
|
||||||
router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => {
|
router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const data = sanitizeProjectBody(req.body, { isUpdate: false });
|
||||||
|
|
||||||
|
if (!data.name) {
|
||||||
|
throw new AppError(400, 'اسم المشروع مطلوب - Project name is required');
|
||||||
|
}
|
||||||
|
if (!data.type) {
|
||||||
|
throw new AppError(400, 'نوع المشروع مطلوب - Project type is required');
|
||||||
|
}
|
||||||
|
if (!data.startDate) {
|
||||||
|
throw new AppError(400, 'تاريخ البدء مطلوب - Start date is required');
|
||||||
|
}
|
||||||
|
|
||||||
const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||||
const project = await prisma.project.create({
|
const project = await prisma.project.create({
|
||||||
data: { ...req.body, projectNumber },
|
data: { ...data, projectNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'PROJECT',
|
entityType: 'PROJECT',
|
||||||
entityId: project.id,
|
entityId: project.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: (req as any).user.id,
|
userId: (req as any).user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(ResponseFormatter.success(project));
|
res.status(201).json(ResponseFormatter.success(project));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -65,33 +238,141 @@ router.post('/projects', authorize('projects', 'projects', 'create'), async (req
|
|||||||
|
|
||||||
router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => {
|
router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const data = sanitizeProjectBody(req.body, { isUpdate: true });
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: req.body,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'PROJECT',
|
||||||
|
entityId: project.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: (req as any).user.id,
|
||||||
|
});
|
||||||
|
|
||||||
res.json(ResponseFormatter.success(project));
|
res.json(ResponseFormatter.success(project));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/projects/:id',
|
||||||
|
authorize('projects', 'projects', 'delete'),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Block delete when the project still has tasks / phases / etc.
|
||||||
|
const counts = await prisma.project.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
tasks: true,
|
||||||
|
phases: true,
|
||||||
|
members: true,
|
||||||
|
expenses: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!counts) {
|
||||||
|
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = counts._count;
|
||||||
|
if (c.tasks > 0 || c.phases > 0 || c.expenses > 0) {
|
||||||
|
throw new AppError(
|
||||||
|
409,
|
||||||
|
`لا يمكن حذف المشروع - يحتوي على ${c.tasks} مهمة، ${c.phases} مرحلة، ${c.expenses} مصروف. احذفها أولاً.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach members (safe - they're just join rows) then delete
|
||||||
|
if (c.members > 0) {
|
||||||
|
await prisma.projectMember.deleteMany({ where: { projectId: req.params.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.delete({ where: { id: req.params.id } });
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'PROJECT',
|
||||||
|
entityId: req.params.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
userId: (req as any).user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المشروع - Project deleted'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
// Tasks
|
// Tasks
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
||||||
|
const pageSize = Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(1, parseInt(String(req.query.pageSize || '20'), 10) || 20),
|
||||||
|
);
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (req.query.projectId) where.projectId = req.query.projectId;
|
if (req.query.projectId) where.projectId = req.query.projectId;
|
||||||
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
|
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
|
||||||
if (req.query.status) where.status = req.query.status;
|
if (req.query.status) where.status = req.query.status;
|
||||||
|
if (req.query.priority) where.priority = req.query.priority;
|
||||||
const tasks = await prisma.task.findMany({
|
|
||||||
where,
|
if (req.query.search) {
|
||||||
|
const q = String(req.query.search);
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ taskNumber: { contains: q, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tasks, total] = await Promise.all([
|
||||||
|
prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
assignedTo: { select: { id: true, email: true, username: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.task.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(ResponseFormatter.paginated(tasks, total, page, pageSize));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/tasks/:id', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
assignedTo: { select: { email: true, username: true } },
|
assignedTo: { select: { id: true, email: true, username: true } },
|
||||||
|
phase: true,
|
||||||
|
parent: true,
|
||||||
|
children: true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
});
|
||||||
res.json(ResponseFormatter.success(tasks));
|
if (!task) {
|
||||||
|
throw new AppError(404, 'المهمة غير موجودة - Task not found');
|
||||||
|
}
|
||||||
|
res.json(ResponseFormatter.success(task));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -99,12 +380,18 @@ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, ne
|
|||||||
|
|
||||||
router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => {
|
router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const data = sanitizeTaskBody(req.body, { isUpdate: false });
|
||||||
|
|
||||||
|
if (!data.title) {
|
||||||
|
throw new AppError(400, 'عنوان المهمة مطلوب - Task title is required');
|
||||||
|
}
|
||||||
|
|
||||||
const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||||
const task = await prisma.task.create({
|
const task = await prisma.task.create({
|
||||||
data: { ...req.body, taskNumber },
|
data: { ...data, taskNumber },
|
||||||
include: { project: true, assignedTo: true },
|
include: { project: true, assignedTo: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create notification for assigned user
|
// Create notification for assigned user
|
||||||
if (task.assignedToId) {
|
if (task.assignedToId) {
|
||||||
await prisma.notification.create({
|
await prisma.notification.create({
|
||||||
@@ -118,7 +405,14 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'TASK',
|
||||||
|
entityId: task.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: (req as any).user.id,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(ResponseFormatter.success(task));
|
res.status(201).json(ResponseFormatter.success(task));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -127,15 +421,41 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
|||||||
|
|
||||||
router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => {
|
router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const data = sanitizeTaskBody(req.body, { isUpdate: true });
|
||||||
const task = await prisma.task.update({
|
const task = await prisma.task.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: req.body,
|
data,
|
||||||
|
include: { project: true, assignedTo: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'TASK',
|
||||||
|
entityId: task.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: (req as any).user.id,
|
||||||
|
});
|
||||||
|
|
||||||
res.json(ResponseFormatter.success(task));
|
res.json(ResponseFormatter.success(task));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
router.delete('/tasks/:id', authorize('projects', 'tasks', 'delete'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await prisma.task.delete({ where: { id: req.params.id } });
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'TASK',
|
||||||
|
entityId: req.params.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
userId: (req as any).user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المهمة - Task deleted'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|||||||
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) {
|
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
@@ -180,6 +194,19 @@ export class TendersController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAssignableEmployees(
|
||||||
|
_req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const employees = await tendersService.getAssignableEmployees();
|
||||||
|
res.json(ResponseFormatter.success(employees));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
|
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -236,8 +263,12 @@ export class TendersController {
|
|||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
|
console.error('[tenders.viewAttachment] Resolved path missing at send time', {
|
||||||
|
attachmentId: req.params.attachmentId,
|
||||||
|
resolvedPath: file,
|
||||||
|
})
|
||||||
return res.status(404).json(
|
return res.status(404).json(
|
||||||
ResponseFormatter.error('File not found', 'الملف غير موجود')
|
ResponseFormatter.error('File not found - الملف غير موجود', 'FILE_NOT_FOUND')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +276,7 @@ export class TendersController {
|
|||||||
return res.sendFile(path.resolve(file))
|
return res.sendFile(path.resolve(file))
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error('[tenders.viewAttachment]', error)
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,17 @@ if (!fs.existsSync(uploadDir)) {
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
// but multer/busboy decode them as latin1 by default. Reverse it so
|
||||||
|
// Arabic filenames are stored intact in the DB.
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(file.originalname || '', 'latin1').toString('utf8');
|
||||||
|
file.originalname = decoded;
|
||||||
|
} catch {
|
||||||
|
// keep as-is
|
||||||
|
}
|
||||||
|
const extName = path.extname(file.originalname || '') || '';
|
||||||
|
cb(null, `${crypto.randomUUID()}${extName}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
@@ -56,6 +65,16 @@ router.get(
|
|||||||
tendersController.getDirectiveTypeValues
|
tendersController.getDirectiveTypeValues
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Minimal employee list for directive assignee dropdown.
|
||||||
|
// Guarded by directive-create permission so users who can issue directives
|
||||||
|
// can populate the dropdown WITHOUT being granted hr:employees:read
|
||||||
|
// (which would expose salaries, national IDs, and other sensitive HR data).
|
||||||
|
router.get(
|
||||||
|
'/assignable-employees',
|
||||||
|
authorize('tenders', 'directives', 'create'),
|
||||||
|
tendersController.getAssignableEmployees
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/check-duplicates',
|
'/check-duplicates',
|
||||||
authorize('tenders', 'tenders', 'create'),
|
authorize('tenders', 'tenders', 'create'),
|
||||||
@@ -83,6 +102,7 @@ router.post(
|
|||||||
authorize('tenders', 'tenders', 'create'),
|
authorize('tenders', 'tenders', 'create'),
|
||||||
[
|
[
|
||||||
body('tenderNumber').notEmpty().trim(),
|
body('tenderNumber').notEmpty().trim(),
|
||||||
|
body('issueNumber').optional().trim(),
|
||||||
body('issuingBodyName').notEmpty().trim(),
|
body('issuingBodyName').notEmpty().trim(),
|
||||||
body('title').notEmpty().trim(),
|
body('title').notEmpty().trim(),
|
||||||
body('termsValue').isNumeric(),
|
body('termsValue').isNumeric(),
|
||||||
@@ -112,6 +132,14 @@ router.put(
|
|||||||
tendersController.update
|
tendersController.update
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authorize('tenders', 'tenders', 'delete'),
|
||||||
|
param('id').isUUID(),
|
||||||
|
validate,
|
||||||
|
tendersController.delete
|
||||||
|
);
|
||||||
|
|
||||||
// Tender history
|
// Tender history
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/history',
|
'/:id/history',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
|
||||||
const TENDER_SOURCE_VALUES = [
|
const TENDER_SOURCE_VALUES = [
|
||||||
@@ -35,6 +36,7 @@ export interface CreateTenderData {
|
|||||||
issuingBodyName: string;
|
issuingBodyName: string;
|
||||||
title: string;
|
title: string;
|
||||||
tenderNumber: string;
|
tenderNumber: string;
|
||||||
|
issueNumber?: string;
|
||||||
|
|
||||||
termsValue: number;
|
termsValue: number;
|
||||||
bondValue: number;
|
bondValue: number;
|
||||||
@@ -86,8 +88,52 @@ class TendersService {
|
|||||||
return `TND-${year}-${seq}`;
|
return `TND-${year}-${seq}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
|
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
|
||||||
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
|
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
|
||||||
|
private getCompanyTodayDate(): Date {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Riyadh',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
|
||||||
|
const year = parts.find((p) => p.type === 'year')?.value;
|
||||||
|
const month = parts.find((p) => p.type === 'month')?.value;
|
||||||
|
const day = parts.find((p) => p.type === 'day')?.value;
|
||||||
|
|
||||||
|
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDateOnly(value: Date | string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return new Date(`${year}-${month}-${day}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEffectiveTenderStatus(tender: {
|
||||||
|
status?: string | null;
|
||||||
|
closingDate?: Date | string | null;
|
||||||
|
}) {
|
||||||
|
if (tender.status === 'ACTIVE') {
|
||||||
|
const closingDate = this.toDateOnly(tender.closingDate);
|
||||||
|
const today = this.getCompanyTodayDate();
|
||||||
|
|
||||||
|
if (closingDate && closingDate < today) {
|
||||||
|
return 'EXPIRED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tender.status || 'ACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
private extractTenderExtraMeta(notes?: string | null) {
|
private extractTenderExtraMeta(notes?: string | null) {
|
||||||
if (!notes) {
|
if (!notes) {
|
||||||
@@ -125,6 +171,62 @@ class TendersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string) {
|
||||||
|
const tender = await prisma.tender.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
attachments: true,
|
||||||
|
directives: {
|
||||||
|
include: {
|
||||||
|
attachments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
convertedDeal: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tender) {
|
||||||
|
throw new AppError(404, 'Tender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tender.convertedDeal) {
|
||||||
|
throw new AppError(400, 'Cannot delete tender that has been converted to deal');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of tender.attachments || []) {
|
||||||
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||||
|
fs.unlinkSync(attachment.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directive of tender.directives || []) {
|
||||||
|
for (const attachment of directive.attachments || []) {
|
||||||
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||||
|
fs.unlinkSync(attachment.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.tender.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'TENDER',
|
||||||
|
entityId: id,
|
||||||
|
action: 'DELETE',
|
||||||
|
userId,
|
||||||
|
changes: {
|
||||||
|
deletedTenderNumber: tender.tenderNumber,
|
||||||
|
deletedTitle: tender.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private buildTenderNotes(
|
private buildTenderNotes(
|
||||||
plainNotes?: string | null,
|
plainNotes?: string | null,
|
||||||
extra?: {
|
extra?: {
|
||||||
@@ -152,11 +254,17 @@ class TendersService {
|
|||||||
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
|
private mapTenderExtraFields<T extends {
|
||||||
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
notes?: string | null;
|
||||||
|
bondValue?: any;
|
||||||
|
status?: string | null;
|
||||||
|
closingDate?: Date | string | null;
|
||||||
|
}>(tender: T) {
|
||||||
|
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tender,
|
...tender,
|
||||||
|
status: this.getEffectiveTenderStatus(tender),
|
||||||
notes: cleanNotes || null,
|
notes: cleanNotes || null,
|
||||||
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
|
||||||
finalBondValue: meta.finalBondValue ?? null,
|
finalBondValue: meta.finalBondValue ?? null,
|
||||||
@@ -246,6 +354,7 @@ class TendersService {
|
|||||||
const tender = await prisma.tender.create({
|
const tender = await prisma.tender.create({
|
||||||
data: {
|
data: {
|
||||||
tenderNumber,
|
tenderNumber,
|
||||||
|
issueNumber: data.issueNumber?.trim() || null,
|
||||||
issuingBodyName: data.issuingBodyName.trim(),
|
issuingBodyName: data.issuingBodyName.trim(),
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
termsValue: data.termsValue,
|
termsValue: data.termsValue,
|
||||||
@@ -285,11 +394,20 @@ class TendersService {
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ issueNumber: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ title: { contains: filters.search, mode: 'insensitive' } },
|
{ title: { contains: filters.search, mode: 'insensitive' } },
|
||||||
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (filters.status) where.status = filters.status;
|
if (filters.status === 'EXPIRED') {
|
||||||
|
where.status = 'ACTIVE';
|
||||||
|
where.closingDate = { lt: this.getCompanyTodayDate() };
|
||||||
|
} else if (filters.status === 'ACTIVE') {
|
||||||
|
where.status = 'ACTIVE';
|
||||||
|
where.closingDate = { gte: this.getCompanyTodayDate() };
|
||||||
|
} else if (filters.status) {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
if (filters.source) where.source = filters.source;
|
if (filters.source) where.source = filters.source;
|
||||||
if (filters.announcementType) where.announcementType = filters.announcementType;
|
if (filters.announcementType) where.announcementType = filters.announcementType;
|
||||||
|
|
||||||
@@ -381,6 +499,9 @@ class TendersService {
|
|||||||
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
|
||||||
}
|
}
|
||||||
if (data.title !== undefined) updateData.title = data.title.trim();
|
if (data.title !== undefined) updateData.title = data.title.trim();
|
||||||
|
if (data.issueNumber !== undefined) {
|
||||||
|
updateData.issueNumber = data.issueNumber?.trim() || null;
|
||||||
|
}
|
||||||
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
||||||
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
||||||
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
|
||||||
@@ -555,6 +676,27 @@ class TendersService {
|
|||||||
return [...DIRECTIVE_TYPE_VALUES];
|
return [...DIRECTIVE_TYPE_VALUES];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a minimal employee list (id + names only) for filling the
|
||||||
|
* directive assignee dropdown. Intentionally does NOT include salary,
|
||||||
|
* national ID, passport, email, phone, or any other sensitive HR fields
|
||||||
|
* so this endpoint can be exposed to anyone with directive-create
|
||||||
|
* permission without leaking HR data.
|
||||||
|
*/
|
||||||
|
async getAssignableEmployees() {
|
||||||
|
return prisma.employee.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
firstNameAr: true,
|
||||||
|
lastNameAr: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async convertToDeal(
|
async convertToDeal(
|
||||||
tenderId: string,
|
tenderId: string,
|
||||||
data: { contactId: string; pipelineId: string; ownerId?: string },
|
data: { contactId: string; pipelineId: string; ownerId?: string },
|
||||||
@@ -568,6 +710,9 @@ class TendersService {
|
|||||||
if (tender.status === 'CONVERTED_TO_DEAL') {
|
if (tender.status === 'CONVERTED_TO_DEAL') {
|
||||||
throw new AppError(400, 'Tender already converted to deal');
|
throw new AppError(400, 'Tender already converted to deal');
|
||||||
}
|
}
|
||||||
|
if (this.getEffectiveTenderStatus(tender) === 'EXPIRED') {
|
||||||
|
throw new AppError(400, 'Cannot convert expired tender to deal');
|
||||||
|
}
|
||||||
|
|
||||||
const pipeline = await prisma.pipeline.findUnique({
|
const pipeline = await prisma.pipeline.findUnique({
|
||||||
where: { id: data.pipelineId },
|
where: { id: data.pipelineId },
|
||||||
@@ -630,7 +775,27 @@ class TendersService {
|
|||||||
) {
|
) {
|
||||||
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
||||||
if (!tender) throw new AppError(404, 'Tender not found');
|
if (!tender) throw new AppError(404, 'Tender not found');
|
||||||
const fileName = path.basename(file.path);
|
|
||||||
|
const absolutePath = path.resolve(file.path);
|
||||||
|
const fileName = path.basename(absolutePath);
|
||||||
|
|
||||||
|
// Verify multer actually wrote the file to disk before recording it.
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
console.error('[tenders.uploadTenderAttachment] Multer reported a file but it does not exist on disk', {
|
||||||
|
tenderId,
|
||||||
|
multerPath: file.path,
|
||||||
|
resolvedPath: absolutePath,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
throw new AppError(500, 'File upload failed - فشل رفع الملف');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[tenders.uploadTenderAttachment] File saved', {
|
||||||
|
tenderId,
|
||||||
|
path: absolutePath,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
const attachment = await prisma.attachment.create({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
@@ -640,11 +805,12 @@ class TendersService {
|
|||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: absolutePath,
|
||||||
category: category || 'ANNOUNCEMENT',
|
category: category || 'ANNOUNCEMENT',
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER',
|
entityType: 'TENDER',
|
||||||
entityId: tenderId,
|
entityId: tenderId,
|
||||||
@@ -652,6 +818,7 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
changes: { attachmentUploaded: attachment.id },
|
changes: { attachmentUploaded: attachment.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,8 +832,29 @@ class TendersService {
|
|||||||
where: { id: directiveId },
|
where: { id: directiveId },
|
||||||
select: { id: true, tenderId: true },
|
select: { id: true, tenderId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!directive) throw new AppError(404, 'Directive not found');
|
if (!directive) throw new AppError(404, 'Directive not found');
|
||||||
const fileName = path.basename(file.path);
|
|
||||||
|
const absolutePath = path.resolve(file.path);
|
||||||
|
const fileName = path.basename(absolutePath);
|
||||||
|
|
||||||
|
// Verify multer actually wrote the file to disk before recording it.
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
console.error('[tenders.uploadDirectiveAttachment] Multer reported a file but it does not exist on disk', {
|
||||||
|
directiveId,
|
||||||
|
multerPath: file.path,
|
||||||
|
resolvedPath: absolutePath,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
throw new AppError(500, 'File upload failed - فشل رفع الملف');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[tenders.uploadDirectiveAttachment] File saved', {
|
||||||
|
directiveId,
|
||||||
|
path: absolutePath,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
const attachment = await prisma.attachment.create({
|
const attachment = await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
@@ -677,11 +865,12 @@ class TendersService {
|
|||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: file.path,
|
path: absolutePath,
|
||||||
category: category || 'TASK_FILE',
|
category: category || 'TASK_FILE',
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'TENDER_DIRECTIVE',
|
entityType: 'TENDER_DIRECTIVE',
|
||||||
entityId: directiveId,
|
entityId: directiveId,
|
||||||
@@ -689,17 +878,62 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
changes: { attachmentUploaded: attachment.id },
|
changes: { attachmentUploaded: attachment.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||||
const attachment = await prisma.attachment.findUnique({
|
const attachment = await prisma.attachment.findUnique({
|
||||||
where: { id: attachmentId },
|
where: { id: attachmentId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!attachment) throw new AppError(404, 'File not found')
|
if (!attachment) throw new AppError(404, 'File not found');
|
||||||
|
|
||||||
return attachment.path
|
// Try multiple candidate locations for the file (in order of preference).
|
||||||
|
// This makes the system resilient to path changes between deploys (e.g.
|
||||||
|
// when an old DB row has a stale absolute path).
|
||||||
|
const candidates = [
|
||||||
|
attachment.path,
|
||||||
|
path.join(config.upload.path, 'tenders', attachment.fileName),
|
||||||
|
path.join(config.upload.path, attachment.fileName),
|
||||||
|
path.join(process.cwd(), 'uploads', 'tenders', attachment.fileName),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => path.resolve(String(p)));
|
||||||
|
|
||||||
|
const existingPath = candidates.find((p) => fs.existsSync(p));
|
||||||
|
|
||||||
|
if (!existingPath) {
|
||||||
|
console.error('[tenders.getAttachmentFile] File not found on disk', {
|
||||||
|
attachmentId,
|
||||||
|
storedPath: attachment.path,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
uploadConfigPath: config.upload.path,
|
||||||
|
triedCandidates: candidates,
|
||||||
|
});
|
||||||
|
throw new AppError(404, 'File not found - الملف غير موجود');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-healing: if the file lives at a path other than what's stored,
|
||||||
|
// update the DB so future lookups are direct.
|
||||||
|
if (existingPath !== attachment.path) {
|
||||||
|
console.warn('[tenders.getAttachmentFile] Stored path was stale, updating', {
|
||||||
|
attachmentId,
|
||||||
|
oldPath: attachment.path,
|
||||||
|
newPath: existingPath,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await prisma.attachment.update({
|
||||||
|
where: { id: attachmentId },
|
||||||
|
data: { path: existingPath },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal: we still have the resolved path to serve from.
|
||||||
|
console.error('[tenders.getAttachmentFile] Failed to update stale path', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAttachment(attachmentId: string): Promise<void> {
|
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||||
@@ -709,12 +943,10 @@ class TendersService {
|
|||||||
|
|
||||||
if (!attachment) throw new AppError(404, 'File not found')
|
if (!attachment) throw new AppError(404, 'File not found')
|
||||||
|
|
||||||
// حذف من الديسك
|
|
||||||
if (attachment.path && fs.existsSync(attachment.path)) {
|
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||||
fs.unlinkSync(attachment.path)
|
fs.unlinkSync(attachment.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// حذف من DB
|
|
||||||
await prisma.attachment.delete({
|
await prisma.attachment.delete({
|
||||||
where: { id: attachmentId },
|
where: { id: attachmentId },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
|
|||||||
import projectsRoutes from '../modules/projects/projects.routes';
|
import projectsRoutes from '../modules/projects/projects.routes';
|
||||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||||
import tendersRoutes from '../modules/tenders/tenders.routes';
|
import tendersRoutes from '../modules/tenders/tenders.routes';
|
||||||
|
import notificationsRoutes from '../modules/notifications/notifications.routes';
|
||||||
|
import suppliersRoutes from '../modules/suppliers/suppliers.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -17,12 +19,14 @@ router.use('/admin', adminRoutes);
|
|||||||
router.use('/dashboard', dashboardRoutes);
|
router.use('/dashboard', dashboardRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
router.use('/contacts', contactsRoutes);
|
router.use('/contacts', contactsRoutes);
|
||||||
|
router.use('/suppliers', suppliersRoutes);
|
||||||
router.use('/crm', crmRoutes);
|
router.use('/crm', crmRoutes);
|
||||||
router.use('/hr', hrRoutes);
|
router.use('/hr', hrRoutes);
|
||||||
router.use('/inventory', inventoryRoutes);
|
router.use('/inventory', inventoryRoutes);
|
||||||
router.use('/projects', projectsRoutes);
|
router.use('/projects', projectsRoutes);
|
||||||
router.use('/marketing', marketingRoutes);
|
router.use('/marketing', marketingRoutes);
|
||||||
router.use('/tenders', tendersRoutes);
|
router.use('/tenders', tendersRoutes);
|
||||||
|
router.use('/notifications', notificationsRoutes);
|
||||||
|
|
||||||
// API info
|
// API info
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
@@ -33,6 +37,7 @@ router.get('/', (req, res) => {
|
|||||||
modules: [
|
modules: [
|
||||||
'Auth',
|
'Auth',
|
||||||
'Contact Management',
|
'Contact Management',
|
||||||
|
'Supplier Management',
|
||||||
'CRM',
|
'CRM',
|
||||||
'HR Management',
|
'HR Management',
|
||||||
'Inventory & Assets',
|
'Inventory & Assets',
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
||||||
JWT_EXPIRES_IN: 7d
|
JWT_EXPIRES_IN: 7d
|
||||||
JWT_REFRESH_EXPIRES_IN: 30d
|
JWT_REFRESH_EXPIRES_IN: 30d
|
||||||
|
MAX_FILE_SIZE: 52428800
|
||||||
|
UPLOAD_PATH: /app/uploads
|
||||||
BCRYPT_ROUNDS: 10
|
BCRYPT_ROUNDS: 10
|
||||||
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
|
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
|
||||||
|
volumes:
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -67,3 +71,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
backend_uploads:
|
||||||
|
driver: local
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 101 KiB |
@@ -9,13 +9,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
|||||||
|
|
||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||||
|
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
|
||||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
{ id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
|
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||||
|
|
||||||
|
{ id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' },
|
||||||
|
{ id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' },
|
||||||
|
{ id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' },
|
||||||
|
{ id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' },
|
||||||
|
|
||||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
||||||
|
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
|
||||||
|
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
@@ -28,6 +38,8 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||||
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,23 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
|||||||
|
|
||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||||
|
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
|
||||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
|
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||||
|
|
||||||
|
{ id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' },
|
||||||
|
{ id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' },
|
||||||
|
{ id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' },
|
||||||
|
{ id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' },
|
||||||
|
|
||||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
|
||||||
|
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
|
||||||
|
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
@@ -29,6 +39,8 @@ const ACTIONS = [
|
|||||||
{ id: 'delete', name: 'حذف' },
|
{ id: 'delete', name: 'حذف' },
|
||||||
{ id: 'export', name: 'تصدير' },
|
{ id: 'export', name: 'تصدير' },
|
||||||
{ id: 'approve', name: 'اعتماد' },
|
{ id: 'approve', name: 'اعتماد' },
|
||||||
|
{ id: 'mark-as-paid', name: 'تأكيد القبض' },
|
||||||
|
{ id: 'notify', name: 'إشعار' },
|
||||||
{ id: 'merge', name: 'دمج' },
|
{ id: 'merge', name: 'دمج' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ function ContactDetailContent() {
|
|||||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||||
UN: 'bg-sky-100 text-sky-700',
|
UN: 'bg-sky-100 text-sky-700',
|
||||||
NGO: 'bg-pink-100 text-pink-700',
|
NGO: 'bg-pink-100 text-pink-700',
|
||||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
INSTITUTION: 'bg-gray-100 text-gray-700',
|
||||||
|
SUPPLIER: 'bg-emerald-100 text-emerald-700'
|
||||||
}
|
}
|
||||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,8 @@ function ContactDetailContent() {
|
|||||||
SCHOOL: 'مدارس - Schools',
|
SCHOOL: 'مدارس - Schools',
|
||||||
UN: 'UN - United Nations',
|
UN: 'UN - United Nations',
|
||||||
NGO: 'NGO - Non-Governmental Organization',
|
NGO: 'NGO - Non-Governmental Organization',
|
||||||
INSTITUTION: 'مؤسسة - Institution'
|
INSTITUTION: 'مؤسسة - Institution',
|
||||||
|
SUPPLIER: 'مورّد - Supplier'
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
@@ -370,7 +372,7 @@ function ContactDetailContent() {
|
|||||||
{ id: 'address', label: 'Address', icon: MapPin },
|
{ id: 'address', label: 'Address', icon: MapPin },
|
||||||
{ id: 'categories', label: 'Categories & Tags', icon: Tag },
|
{ id: 'categories', label: 'Categories & Tags', icon: Tag },
|
||||||
{ id: 'relationships', label: 'Relationships', icon: Users },
|
{ id: 'relationships', label: 'Relationships', icon: Users },
|
||||||
...((contact.type === 'COMPANY' || contact.type === 'HOLDING')
|
...((['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type))
|
||||||
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
|
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
|
||||||
: []
|
: []
|
||||||
),
|
),
|
||||||
@@ -646,7 +648,7 @@ function ContactDetailContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hierarchy Tab */}
|
{/* Hierarchy Tab */}
|
||||||
{activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && (
|
{activeTab === 'hierarchy' && (['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) && (
|
||||||
<div>
|
<div>
|
||||||
<HierarchyTree rootContactId={contactId} />
|
<HierarchyTree rootContactId={contactId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
|
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
|
||||||
import { categoriesAPI, Category } from '@/lib/api/categories'
|
import { categoriesAPI, Category } from '@/lib/api/categories'
|
||||||
|
import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories'
|
||||||
import ContactForm from '@/components/contacts/ContactForm'
|
import ContactForm from '@/components/contacts/ContactForm'
|
||||||
import ContactImport from '@/components/contacts/ContactImport'
|
import ContactImport from '@/components/contacts/ContactImport'
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ function ContactsContent() {
|
|||||||
const filters: ContactFilters = {
|
const filters: ContactFilters = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
excludeSuppliers: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm) filters.search = searchTerm
|
if (searchTerm) filters.search = searchTerm
|
||||||
@@ -192,7 +194,8 @@ function ContactsContent() {
|
|||||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||||
UN: 'bg-sky-100 text-sky-700',
|
UN: 'bg-sky-100 text-sky-700',
|
||||||
NGO: 'bg-pink-100 text-pink-700',
|
NGO: 'bg-pink-100 text-pink-700',
|
||||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
INSTITUTION: 'bg-gray-100 text-gray-700',
|
||||||
|
SUPPLIER: 'bg-emerald-100 text-emerald-700'
|
||||||
}
|
}
|
||||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -214,7 +217,8 @@ function ContactsContent() {
|
|||||||
SCHOOL: 'مدارس',
|
SCHOOL: 'مدارس',
|
||||||
UN: 'UN',
|
UN: 'UN',
|
||||||
NGO: 'NGO',
|
NGO: 'NGO',
|
||||||
INSTITUTION: 'مؤسسة'
|
INSTITUTION: 'مؤسسة',
|
||||||
|
SUPPLIER: 'مورّد'
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
@@ -231,6 +235,7 @@ function ContactsContent() {
|
|||||||
'UN',
|
'UN',
|
||||||
'NGO',
|
'NGO',
|
||||||
'INSTITUTION',
|
'INSTITUTION',
|
||||||
|
'SUPPLIER',
|
||||||
])
|
])
|
||||||
|
|
||||||
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
|
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
|
||||||
@@ -477,9 +482,11 @@ function ContactsContent() {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="all">All Categories</option>
|
<option value="all">All Categories</option>
|
||||||
{flattenCategories(categories).map((cat) => (
|
{flattenCategories(categories)
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
.filter((cat) => !isSupplierOnlyCategoryName(cat.name, cat.nameAr))
|
||||||
))}
|
.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -582,13 +589,19 @@ function ContactsContent() {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{getListCompanyName(contact) !== '-' && (
|
{getListCompanyName(contact) !== '-' ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Building2 className="h-4 w-4 text-gray-400" />
|
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center text-white font-bold">
|
||||||
<span className="text-sm text-gray-900">
|
{getListCompanyName(contact).charAt(0).toUpperCase()}
|
||||||
{getListCompanyName(contact)}
|
</div>
|
||||||
</span>
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{getListCompanyName(contact)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -609,13 +622,9 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
|
|
||||||
{getListContactName(contact).charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="text-sm text-gray-900">
|
||||||
{getListContactName(contact)}
|
{getListContactName(contact)}
|
||||||
</p>
|
</p>
|
||||||
{getListContactNameAr(contact) && (
|
{getListContactNameAr(contact) && (
|
||||||
@@ -624,8 +633,7 @@ function ContactsContent() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
||||||
@@ -819,6 +827,7 @@ function ContactsContent() {
|
|||||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||||
if (selectedCategory !== 'all') filters.category = selectedCategory
|
if (selectedCategory !== 'all') filters.category = selectedCategory
|
||||||
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
|
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
|
||||||
|
filters.excludeSuppliers = true
|
||||||
|
|
||||||
const blob = await contactsAPI.export(filters)
|
const blob = await contactsAPI.export(filters)
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import logoImage from '@/assets/logo.png'
|
import logoImage from '@/assets/logo.png'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/contexts/AuthContext'
|
|||||||
import { useLanguage } from '@/contexts/LanguageContext'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
User,
|
User,
|
||||||
@@ -21,23 +22,229 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Bell,
|
Bell,
|
||||||
Shield,
|
Shield,
|
||||||
FileText
|
FileText,
|
||||||
|
Truck
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { dashboardAPI } from '@/lib/api'
|
import { dashboardAPI, notificationsAPI } from '@/lib/api'
|
||||||
|
import { portalAPI } from '@/lib/api/portal'
|
||||||
|
import { hrAdminAPI } from '@/lib/api/hrAdmin'
|
||||||
|
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
const { user, logout, hasPermission } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
const { t, language, dir } = useLanguage()
|
const { t, language, dir } = useLanguage()
|
||||||
const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 })
|
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
contacts: 0,
|
||||||
|
activeTasks: 0,
|
||||||
|
notifications: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false)
|
||||||
|
const [notifications, setNotifications] = useState<any[]>([])
|
||||||
|
const [notificationsLoading, setNotificationsLoading] = useState(false)
|
||||||
|
const notificationsRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const [pendingApprovals, setPendingApprovals] = useState({
|
||||||
|
managedLeaves: 0,
|
||||||
|
managedOvertime: 0,
|
||||||
|
purchaseRequests: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dashboardAPI.getStats()
|
dashboardAPI
|
||||||
|
.getStats()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.data?.data) setStats(res.data.data)
|
if (res.data?.data) setStats(res.data.data)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
setNotificationsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await notificationsAPI.getMy({ page: 1, pageSize: 10 })
|
||||||
|
const items = res.data?.data?.notifications || []
|
||||||
|
setNotifications(items)
|
||||||
|
} catch {
|
||||||
|
setNotifications([])
|
||||||
|
} finally {
|
||||||
|
setNotificationsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markNotificationAsRead = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await notificationsAPI.markAsRead(id)
|
||||||
|
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === id
|
||||||
|
? { ...item, isRead: true, readAt: new Date().toISOString() }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
setStats((prev) => ({
|
||||||
|
...prev,
|
||||||
|
notifications: Math.max(0, prev.notifications - 1),
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveNotificationUrl = (notification: any) => {
|
||||||
|
if (notification.entityType === 'LEAVE') {
|
||||||
|
if (notification.type === 'LEAVE_REQUEST_SUBMITTED') {
|
||||||
|
return '/portal/managed-leaves';
|
||||||
|
}
|
||||||
|
return '/portal/leave';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'OVERTIME_REQUEST') {
|
||||||
|
if (notification.type === 'OVERTIME_REQUEST_SUBMITTED') {
|
||||||
|
return '/portal/managed-overtime-requests';
|
||||||
|
}
|
||||||
|
return '/portal/overtime';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'PURCHASE_REQUEST') {
|
||||||
|
if (notification.type === 'PURCHASE_REQUEST_SUBMITTED') {
|
||||||
|
return '/hr?tab=purchases';
|
||||||
|
}
|
||||||
|
return '/portal/purchase-requests';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'LOAN') {
|
||||||
|
if (
|
||||||
|
notification.type === 'LOAN_REQUEST_SUBMITTED' ||
|
||||||
|
notification.type === 'LOAN_REQUEST_PENDING_ADMIN'
|
||||||
|
) {
|
||||||
|
return '/hr?tab=loans';
|
||||||
|
}
|
||||||
|
return '/portal/loans';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'EXPENSE_CLAIM') {
|
||||||
|
if (notification.entityId) {
|
||||||
|
if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') {
|
||||||
|
return `/portal/managed-expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
notification.type === 'EXPENSE_CLAIM_CREATED' ||
|
||||||
|
notification.type === 'EXPENSE_CLAIM_APPROVED' ||
|
||||||
|
notification.type === 'EXPENSE_CLAIM_REJECTED'
|
||||||
|
) {
|
||||||
|
return `/portal/expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return `/portal/expense-claims?claimId=${notification.entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/portal/expense-claims';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.type === 'TENDER_DIRECTIVE_ASSIGNED') {
|
||||||
|
if (notification.entityType === 'TENDER' && notification.entityId) {
|
||||||
|
return `/tenders/${notification.entityId}?tab=directives`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/tenders';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: any) => {
|
||||||
|
try {
|
||||||
|
if (!notification.isRead) {
|
||||||
|
await markNotificationAsRead(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = resolveNotificationUrl(notification)
|
||||||
|
|
||||||
|
console.log('🔔 Notification click →', notification)
|
||||||
|
console.log('➡️ Redirecting to:', targetUrl)
|
||||||
|
|
||||||
|
setShowNotifications(false)
|
||||||
|
|
||||||
|
router.push(targetUrl)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = targetUrl
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Notification click error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleNotifications = async () => {
|
||||||
|
const next = !showNotifications
|
||||||
|
setShowNotifications(next)
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPendingApprovals = async () => {
|
||||||
|
try {
|
||||||
|
const [managedLeaves, managedOvertime, purchaseRequests] = await Promise.all([
|
||||||
|
canViewManagedLeaves
|
||||||
|
? portalAPI.getManagedLeaves('PENDING')
|
||||||
|
: Promise.resolve([]),
|
||||||
|
canViewManagedOvertime
|
||||||
|
? portalAPI.getManagedOvertimeRequests()
|
||||||
|
: Promise.resolve([]),
|
||||||
|
canApproveHr
|
||||||
|
? hrAdminAPI
|
||||||
|
.getPurchaseRequests({ status: 'PENDING', page: 1, pageSize: 50 })
|
||||||
|
.then((r) => r.purchaseRequests || [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total =
|
||||||
|
managedLeaves.length +
|
||||||
|
managedOvertime.length +
|
||||||
|
purchaseRequests.length
|
||||||
|
|
||||||
|
setPendingApprovals({
|
||||||
|
managedLeaves: managedLeaves.length,
|
||||||
|
managedOvertime: managedOvertime.length,
|
||||||
|
purchaseRequests: purchaseRequests.length,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setPendingApprovals({
|
||||||
|
managedLeaves: 0,
|
||||||
|
managedOvertime: 0,
|
||||||
|
purchaseRequests: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
notificationsRef.current &&
|
||||||
|
!notificationsRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowNotifications(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const allModules = [
|
const allModules = [
|
||||||
{
|
{
|
||||||
id: 'contacts',
|
id: 'contacts',
|
||||||
@@ -46,9 +253,19 @@ function DashboardContent() {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
color: 'bg-blue-500',
|
color: 'bg-blue-500',
|
||||||
href: '/contacts',
|
href: '/contacts',
|
||||||
description: 'إدارة العملاء والموردين وجهات الاتصال',
|
description: 'إدارة العملاء وجهات الاتصال',
|
||||||
permission: 'contacts'
|
permission: 'contacts'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'suppliers',
|
||||||
|
name: 'إدارة الموردين',
|
||||||
|
nameEn: 'Supplier Management',
|
||||||
|
icon: Truck,
|
||||||
|
color: 'bg-emerald-500',
|
||||||
|
href: '/suppliers',
|
||||||
|
description: 'إدارة الموردين وبيانات التواصل والاعتماد',
|
||||||
|
permission: 'suppliers'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'crm',
|
id: 'crm',
|
||||||
name: 'إدارة علاقات العملاء',
|
name: 'إدارة علاقات العملاء',
|
||||||
@@ -135,6 +352,9 @@ function DashboardContent() {
|
|||||||
const availableModules = allModules.filter(module =>
|
const availableModules = allModules.filter(module =>
|
||||||
hasPermission(module.permission, 'view')
|
hasPermission(module.permission, 'view')
|
||||||
)
|
)
|
||||||
|
const canViewManagedLeaves = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canViewManagedOvertime = hasPermission('department_overtime_requests', 'view')
|
||||||
|
const canApproveHr = hasPermission('hr', 'approve')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
@@ -182,12 +402,86 @@ function DashboardContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
<div className="relative" ref={notificationsRef}>
|
||||||
<Bell className="h-5 w-5 text-gray-600" />
|
<button
|
||||||
{stats.notifications > 0 && (
|
onClick={handleToggleNotifications}
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative"
|
||||||
|
title="الإشعارات"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5 text-gray-600" />
|
||||||
|
{stats.notifications > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute left-0 mt-2 w-96 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">الإشعارات</h3>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await notificationsAPI.markAllAsRead()
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) => ({
|
||||||
|
...item,
|
||||||
|
isRead: true,
|
||||||
|
readAt: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setStats((prev) => ({ ...prev, notifications: 0 }))
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
تعليم الكل كمقروء
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notificationsLoading ? (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">
|
||||||
|
جاري تحميل الإشعارات...
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-gray-500 text-center">
|
||||||
|
لا توجد إشعارات
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<button
|
||||||
|
key={notification.id}
|
||||||
|
onClick={() => handleNotificationClick(notification)}
|
||||||
|
className={`w-full text-right px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||||
|
notification.isRead ? 'bg-white' : 'bg-blue-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2">
|
||||||
|
{new Date(notification.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!notification.isRead && (
|
||||||
|
<span className="mt-1 h-2.5 w-2.5 rounded-full bg-blue-500 flex-shrink-0"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
@@ -268,6 +562,65 @@ function DashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Approvals */}
|
||||||
|
{pendingApprovals.total > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">بانتظار موافقتك</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
كل الطلبات التي تحتاج قرارك الآن
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-100 text-amber-700 px-4 py-2 rounded-lg font-bold">
|
||||||
|
{pendingApprovals.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{canViewManagedLeaves && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/portal/managed-leaves')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات إجازات القسم</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.managedLeaves}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewManagedOvertime && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/portal/managed-overtime-requests')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات الساعات الإضافية</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.managedOvertime}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canApproveHr && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/hr?tab=purchases')}
|
||||||
|
className="text-right border border-gray-200 rounded-xl p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">طلبات الشراء</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
{pendingApprovals.purchaseRequests}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">فتح الصفحة</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Available Modules */}
|
{/* Available Modules */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>
|
<h3 className="text-2xl font-bold text-gray-900 mb-6">الوحدات المتاحة</h3>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
@@ -245,6 +246,8 @@ function EmployeeFormFields({
|
|||||||
|
|
||||||
function HRContent() {
|
function HRContent() {
|
||||||
// State Management
|
// State Management
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const [employees, setEmployees] = useState<Employee[]>([])
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -296,7 +299,13 @@ function HRContent() {
|
|||||||
const [loadingDepts, setLoadingDepts] = useState(false)
|
const [loadingDepts, setLoadingDepts] = useState(false)
|
||||||
|
|
||||||
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
|
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
|
||||||
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees')
|
type HRTab = 'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<HRTab>('employees')
|
||||||
|
const openTab = (tab: HRTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(`/hr?tab=${tab}`)
|
||||||
|
}
|
||||||
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
||||||
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
||||||
|
|
||||||
@@ -391,6 +400,24 @@ function HRContent() {
|
|||||||
}
|
}
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get('tab') as HRTab | null
|
||||||
|
|
||||||
|
const allowedTabs: HRTab[] = [
|
||||||
|
'employees',
|
||||||
|
'departments',
|
||||||
|
'orgchart',
|
||||||
|
'leaves',
|
||||||
|
'loans',
|
||||||
|
'purchases',
|
||||||
|
'contracts',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (tabParam && allowedTabs.includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
// Fetch Employees (with debouncing for search)
|
// Fetch Employees (with debouncing for search)
|
||||||
const fetchEmployees = useCallback(async () => {
|
const fetchEmployees = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -721,7 +748,7 @@ function HRContent() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<nav className="flex gap-4">
|
<nav className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('employees')}
|
onClick={() => openTab('employees')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'employees'
|
activeTab === 'employees'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -734,7 +761,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('departments')}
|
onClick={() => openTab('departments')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'departments'
|
activeTab === 'departments'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -747,7 +774,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('orgchart')}
|
onClick={() => openTab('orgchart')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'orgchart'
|
activeTab === 'orgchart'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -760,7 +787,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('leaves')}
|
onClick={() => openTab('leaves')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'leaves'
|
activeTab === 'leaves'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -773,7 +800,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('loans')}
|
onClick={() => openTab('loans')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'loans'
|
activeTab === 'loans'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -786,7 +813,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('purchases')}
|
onClick={() => openTab('purchases')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'purchases'
|
activeTab === 'purchases'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
@@ -799,7 +826,7 @@ function HRContent() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('contracts')}
|
onClick={() => openTab('contracts')}
|
||||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'contracts'
|
activeTab === 'contracts'
|
||||||
? 'border-red-600 text-red-600'
|
? 'border-red-600 text-red-600'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Package,
|
Package,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
|
Truck,
|
||||||
LogIn
|
LogIn
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -39,7 +40,12 @@ export default function Home() {
|
|||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
title: 'إدارة جهات الاتصال',
|
title: 'إدارة جهات الاتصال',
|
||||||
description: 'نظام شامل لإدارة العملاء والموردين وجهات الاتصال'
|
description: 'نظام شامل لإدارة العملاء وجهات الاتصال'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Truck,
|
||||||
|
title: 'إدارة الموردين',
|
||||||
|
description: 'وحدة مستقلة لإدارة الموردين وبيانات الاعتماد والتواصل'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
|
|||||||
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,
|
Building2,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
|
FileText,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
TimerReset,
|
TimerReset,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -26,6 +27,7 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
||||||
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
||||||
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
||||||
|
{ icon: FileText, label: 'كشوف المصاريف', labelEn: 'Expense Claims', href: '/portal/expense-claims' },
|
||||||
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
|
{ icon: TimerReset, label: 'الساعات الإضافية', labelEn: 'Overtime', href: '/portal/overtime' },
|
||||||
...(hasPermission('department_overtime_requests', 'view')
|
...(hasPermission('department_overtime_requests', 'view')
|
||||||
? [{
|
? [{
|
||||||
@@ -35,12 +37,20 @@ function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
href: '/portal/managed-overtime-requests'
|
href: '/portal/managed-overtime-requests'
|
||||||
}]
|
}]
|
||||||
: []),
|
: []),
|
||||||
...(hasPermission('department_leave_requests', 'view')
|
...(hasPermission('department_expense_claims', 'view')
|
||||||
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
|
? [{
|
||||||
: []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
|
icon: CheckCircle2,
|
||||||
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
|
label: 'طلبات كشوف المصاريف',
|
||||||
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
labelEn: 'Department Expense Claims',
|
||||||
]
|
href: '/portal/managed-expense-claims'
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(hasPermission('department_leave_requests', 'view')
|
||||||
|
? [{ icon: CheckCircle2, label: 'طلبات إجازات القسم', labelEn: 'Department Leave Requests', href: '/portal/managed-leaves' }]
|
||||||
|
: []), { icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
|
||||||
|
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
|
||||||
|
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
||||||
|
]
|
||||||
|
|
||||||
const isActive = (href: string, exact?: boolean) => {
|
const isActive = (href: string, exact?: boolean) => {
|
||||||
if (exact) return pathname === href
|
if (exact) return pathname === href
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import LoadingSpinner from '@/components/LoadingSpinner'
|
|||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
|
||||||
|
const hour = Math.floor(i / 2).toString().padStart(2, '0')
|
||||||
|
const minute = i % 2 === 0 ? '00' : '30'
|
||||||
|
return `${hour}:${minute}`
|
||||||
|
})
|
||||||
|
|
||||||
const LEAVE_TYPES = [
|
const LEAVE_TYPES = [
|
||||||
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
||||||
{ value: 'HOURLY', label: 'إجازة ساعية' },
|
{ value: 'HOURLY', label: 'إجازة ساعية' },
|
||||||
@@ -18,12 +24,35 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|||||||
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
|
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPANY_TIME_ZONE = 'Asia/Riyadh'
|
||||||
|
const COMPANY_UTC_OFFSET = '+03:00'
|
||||||
|
|
||||||
|
const toCompanyDateTime = (date: string, time: string) => {
|
||||||
|
return `${date}T${time}:00${COMPANY_UTC_OFFSET}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCompanyTime = (value: string) => {
|
||||||
|
return new Date(value).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCompanyDate = (value: string) => {
|
||||||
|
return new Date(value).toLocaleDateString('ar-SA', {
|
||||||
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function PortalLeavePage() {
|
export default function PortalLeavePage() {
|
||||||
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
|
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
|
||||||
const [leaves, setLeaves] = useState<any[]>([])
|
const [leaves, setLeaves] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
leaveType: 'ANNUAL',
|
leaveType: 'ANNUAL',
|
||||||
@@ -48,6 +77,66 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
useEffect(() => load(), [])
|
useEffect(() => load(), [])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (l: any) => {
|
||||||
|
setEditingId(l.id)
|
||||||
|
if (l.leaveType === 'HOURLY') {
|
||||||
|
const start = new Date(l.startDate)
|
||||||
|
const end = new Date(l.endDate)
|
||||||
|
const dateStr = start.toLocaleDateString('en-CA', { timeZone: COMPANY_TIME_ZONE })
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
d.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
|
})
|
||||||
|
setForm({
|
||||||
|
leaveType: 'HOURLY',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: dateStr,
|
||||||
|
startTime: fmt(start),
|
||||||
|
endTime: fmt(end),
|
||||||
|
reason: l.reason || '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: String(l.startDate).split('T')[0],
|
||||||
|
endDate: String(l.endDate).split('T')[0],
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: l.reason || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('حذف طلب الإجازة؟')) return
|
||||||
|
try {
|
||||||
|
await portalAPI.deleteLeaveRequest(id)
|
||||||
|
toast.success('تم حذف الطلب')
|
||||||
|
load()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -80,28 +169,35 @@ export default function PortalLeavePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
|
payload.leaveDate = form.leaveDate
|
||||||
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
|
payload.startTime = form.startTime
|
||||||
}
|
payload.endTime = form.endTime
|
||||||
|
payload.startDate = toCompanyDateTime(form.leaveDate, form.startTime)
|
||||||
|
payload.endDate = toCompanyDateTime(form.leaveDate, form.endTime)
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
|
||||||
portalAPI.submitLeaveRequest(payload)
|
const action = editingId
|
||||||
|
? portalAPI.updateLeaveRequest(editingId, payload)
|
||||||
|
: portalAPI.submitLeaveRequest(payload)
|
||||||
|
|
||||||
|
action
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({
|
resetForm()
|
||||||
leaveType: 'ANNUAL',
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الإجازة')
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
leaveDate: '',
|
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
reason: '',
|
|
||||||
})
|
|
||||||
toast.success('تم إرسال طلب الإجازة')
|
|
||||||
load()
|
load()
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
.catch((err: any) => {
|
||||||
|
const message =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
'فشل إرسال الطلب'
|
||||||
|
|
||||||
|
console.error('Leave request error:', err.response?.data || err)
|
||||||
|
toast.error(message)
|
||||||
|
})
|
||||||
.finally(() => setSubmitting(false))
|
.finally(() => setSubmitting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +210,7 @@ export default function PortalLeavePage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => { resetForm(); setShowModal(true) }}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -156,18 +252,38 @@ export default function PortalLeavePage() {
|
|||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
|
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
|
||||||
{l.leaveType === 'HOURLY'
|
{l.leaveType === 'HOURLY'
|
||||||
? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
? `${formatCompanyTime(l.startDate)} - ${formatCompanyTime(l.endDate)}`
|
||||||
: `${l.days} يوم`}
|
: `${l.days} يوم`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
|
{formatCompanyDate(l.startDate)} - {formatCompanyDate(l.endDate)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
<div className="flex items-center gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
{l.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(l)}
|
||||||
|
className="text-xs text-teal-600 hover:underline"
|
||||||
|
>
|
||||||
|
تعديل
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(l.id)}
|
||||||
|
className="text-xs text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -176,7 +292,11 @@ export default function PortalLeavePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* الفورم */}
|
{/* الفورم */}
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب الإجازة' : 'طلب إجازة جديد'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|
||||||
{/* نوع الإجازة */}
|
{/* نوع الإجازة */}
|
||||||
@@ -238,22 +358,30 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm">من الساعة</label>
|
<label className="text-sm">من الساعة</label>
|
||||||
<input
|
<select
|
||||||
type="time"
|
|
||||||
value={form.startTime}
|
value={form.startTime}
|
||||||
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
|
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
|
||||||
className="border p-2 rounded w-full"
|
className="border p-2 rounded w-full"
|
||||||
/>
|
>
|
||||||
|
<option value="">اختر الوقت</option>
|
||||||
|
{TIME_OPTIONS.map((time) => (
|
||||||
|
<option key={time} value={time}>{time}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm">إلى الساعة</label>
|
<label className="text-sm">إلى الساعة</label>
|
||||||
<input
|
<select
|
||||||
type="time"
|
|
||||||
value={form.endTime}
|
value={form.endTime}
|
||||||
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
|
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
|
||||||
className="border p-2 rounded w-full"
|
className="border p-2 rounded w-full"
|
||||||
/>
|
>
|
||||||
|
<option value="">اختر الوقت</option>
|
||||||
|
{TIME_OPTIONS.map((time) => (
|
||||||
|
<option key={time} value={time}>{time}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +398,7 @@ export default function PortalLeavePage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => { setShowModal(false); resetForm() }}
|
||||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||||
>
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
@@ -281,7 +409,7 @@ export default function PortalLeavePage() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function PortalLoansPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,6 +30,33 @@ export default function PortalLoansPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (loan: Loan) => {
|
||||||
|
setEditingId(loan.id)
|
||||||
|
setForm({
|
||||||
|
type: loan.type,
|
||||||
|
amount: String(loan.amount ?? ''),
|
||||||
|
installments: String(loan.installments ?? '1'),
|
||||||
|
reason: loan.reason || '',
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('حذف طلب القرض؟')) return
|
||||||
|
try {
|
||||||
|
await portalAPI.deleteLoanRequest(id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
setLoans((prev) => prev.filter((l) => l.id !== id))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const amount = parseFloat(form.amount)
|
const amount = parseFloat(form.amount)
|
||||||
@@ -44,19 +72,27 @@ export default function PortalLoansPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitLoanRequest({
|
const payload = {
|
||||||
type: form.type,
|
type: form.type,
|
||||||
amount,
|
amount,
|
||||||
installments: parseInt(form.installments) || 1,
|
installments: parseInt(form.installments) || 1,
|
||||||
reason: form.reason.trim(),
|
reason: form.reason.trim(),
|
||||||
})
|
}
|
||||||
|
const action = editingId
|
||||||
|
? portalAPI.updateLoanRequest(editingId, payload)
|
||||||
|
: portalAPI.submitLoanRequest(payload)
|
||||||
|
action
|
||||||
.then((loan) => {
|
.then((loan) => {
|
||||||
setLoans((prev) => [loan, ...prev])
|
if (editingId) {
|
||||||
|
setLoans((prev) => prev.map((l) => (l.id === editingId ? loan : l)))
|
||||||
|
} else {
|
||||||
|
setLoans((prev) => [loan, ...prev])
|
||||||
|
}
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
|
resetForm()
|
||||||
toast.success('تم إرسال طلب القرض')
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب القرض')
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||||
.finally(() => setSubmitting(false))
|
.finally(() => setSubmitting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +103,7 @@ export default function PortalLoansPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => { resetForm(); setShowModal(true) }}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -113,9 +149,17 @@ export default function PortalLoansPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
{loan.status === 'PENDING_HR' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => openEdit(loan)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||||
|
<button type="button" onClick={() => handleDelete(loan.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loan.rejectedReason && (
|
{loan.rejectedReason && (
|
||||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
|
||||||
@@ -126,7 +170,11 @@ export default function PortalLoansPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب القرض' : 'طلب قرض جديد'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
|
||||||
@@ -177,7 +225,7 @@ export default function PortalLoansPage() {
|
|||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
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 { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
|
||||||
|
const COMPANY_TIME_ZONE = 'Asia/Riyadh'
|
||||||
|
|
||||||
|
const formatCompanyTime = (value: string) => {
|
||||||
|
return new Date(value).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCompanyDateTime = (value: string) => {
|
||||||
|
return new Date(value).toLocaleString('ar-SA', {
|
||||||
|
timeZone: COMPANY_TIME_ZONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function ManagedLeavesPage() {
|
export default function ManagedLeavesPage() {
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
|
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
|
||||||
@@ -136,15 +153,15 @@ export default function ManagedLeavesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4 text-gray-400" />
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<p>{new Date(leave.startDate).toLocaleString()}</p>
|
<p>{formatCompanyDateTime(leave.startDate)}</p>
|
||||||
<p>{new Date(leave.endDate).toLocaleString()}</p>
|
<p>{formatCompanyDateTime(leave.endDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-6 py-4 text-gray-900">
|
<td className="px-6 py-4 text-gray-900">
|
||||||
{leave.leaveType === 'HOURLY'
|
{leave.leaveType === 'HOURLY'
|
||||||
? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
? `${formatCompanyTime(leave.startDate)} - ${formatCompanyTime(leave.endDate)}`
|
||||||
: `${leave.days} يوم`}
|
: `${leave.days} يوم`}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function PortalOvertimePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
date: '',
|
date: '',
|
||||||
@@ -41,6 +42,32 @@ export default function PortalOvertimePage() {
|
|||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({ date: '', hours: '', reason: '' })
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (item: PortalOvertimeRequest) => {
|
||||||
|
setEditingId(item.attendanceId || item.id)
|
||||||
|
setForm({
|
||||||
|
date: String(item.date).split('T')[0],
|
||||||
|
hours: String(item.hours ?? ''),
|
||||||
|
reason: item.reason || '',
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('حذف طلب الساعات الإضافية؟')) return
|
||||||
|
try {
|
||||||
|
await portalAPI.deleteOvertimeRequest(id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -63,14 +90,22 @@ export default function PortalOvertimePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
await portalAPI.submitOvertimeRequest({
|
if (editingId) {
|
||||||
date: form.date,
|
await portalAPI.updateOvertimeRequest(editingId, {
|
||||||
hours,
|
hours,
|
||||||
reason: form.reason.trim(),
|
reason: form.reason.trim(),
|
||||||
})
|
})
|
||||||
toast.success('تم إرسال الطلب')
|
toast.success('تم تعديل الطلب')
|
||||||
|
} else {
|
||||||
|
await portalAPI.submitOvertimeRequest({
|
||||||
|
date: form.date,
|
||||||
|
hours,
|
||||||
|
reason: form.reason.trim(),
|
||||||
|
})
|
||||||
|
toast.success('تم إرسال الطلب')
|
||||||
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setForm({ date: '', hours: '', reason: '' })
|
resetForm()
|
||||||
loadData()
|
loadData()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||||
@@ -90,7 +125,7 @@ export default function PortalOvertimePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => { resetForm(); setOpen(true) }}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -121,9 +156,17 @@ export default function PortalOvertimePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{meta.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||||
</span>
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{item.status === 'PENDING' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => openEdit(item)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||||
|
<button type="button" onClick={() => handleDelete(item.attendanceId || item.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -131,7 +174,11 @@ export default function PortalOvertimePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => { setOpen(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب الساعات الإضافية' : 'طلب ساعات إضافية'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||||
@@ -140,6 +187,7 @@ export default function PortalOvertimePage() {
|
|||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
disabled={!!editingId}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +219,7 @@ export default function PortalOvertimePage() {
|
|||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => { setOpen(false); resetForm() }}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||||
>
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
@@ -181,7 +229,7 @@ export default function PortalOvertimePage() {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
{submitting ? 'جارٍ الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link'
|
|||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
|
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2,FileText } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export default function PortalDashboardPage() {
|
export default function PortalDashboardPage() {
|
||||||
@@ -25,7 +25,7 @@ export default function PortalDashboardPage() {
|
|||||||
|
|
||||||
const { employee, stats } = data
|
const { employee, stats } = data
|
||||||
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canViewDepartmentExpenseClaims = hasPermission('department_expense_claims', 'view')
|
||||||
const name = employee.firstNameAr && employee.lastNameAr
|
const name = employee.firstNameAr && employee.lastNameAr
|
||||||
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
||||||
: `${employee.firstName} ${employee.lastName}`
|
: `${employee.firstName} ${employee.lastName}`
|
||||||
@@ -89,12 +89,27 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">كشوف المصاريف المعلقة</p>
|
||||||
|
<p className="text-2xl font-bold text-fuchsia-600 mt-1">{stats.pendingExpenseClaimsCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-fuchsia-100 p-3 rounded-lg">
|
||||||
|
<FileText className="h-6 w-6 text-fuchsia-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/portal/expense-claims" className="mt-4 text-sm text-fuchsia-600 hover:underline flex items-center gap-1">
|
||||||
|
عرض الكشوف <ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
|
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
|
||||||
<p className="text-2xl font-bold text-blue-600 mt-1">
|
<p className="text-2xl font-bold text-blue-600 mt-1">
|
||||||
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
|
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount + stats.pendingExpenseClaimsCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-100 p-3 rounded-lg">
|
<div className="bg-blue-100 p-3 rounded-lg">
|
||||||
@@ -104,6 +119,7 @@ export default function PortalDashboardPage() {
|
|||||||
<div className="mt-4 flex gap-4">
|
<div className="mt-4 flex gap-4">
|
||||||
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
|
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
|
||||||
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
|
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
|
||||||
|
<Link href="/portal/expense-claims" className="text-sm text-blue-600 hover:underline">المصاريف</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,6 +187,11 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/portal/expense-claims" className="inline-flex items-center gap-2 px-4 py-2 bg-fuchsia-600 text-white rounded-lg hover:bg-fuchsia-700">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
كشف مصاريف
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب شراء
|
طلب شراء
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||||
reason: '',
|
reason: '',
|
||||||
@@ -41,6 +42,35 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (pr: PurchaseRequest) => {
|
||||||
|
setEditingId(pr.id)
|
||||||
|
const items = Array.isArray(pr.items) && pr.items.length > 0
|
||||||
|
? pr.items.map((it: any) => ({
|
||||||
|
description: String(it.description || ''),
|
||||||
|
quantity: Number(it.quantity || 1),
|
||||||
|
estimatedPrice: String(it.estimatedPrice ?? ''),
|
||||||
|
}))
|
||||||
|
: [{ description: '', quantity: 1, estimatedPrice: '' }]
|
||||||
|
setForm({ items, reason: pr.reason || '', priority: pr.priority || 'NORMAL' })
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('حذف طلب الشراء؟')) return
|
||||||
|
try {
|
||||||
|
await portalAPI.deletePurchaseRequest(id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
setRequests((prev) => prev.filter((r) => r.id !== id))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.message || 'فشل الحذف')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const items = form.items
|
const items = form.items
|
||||||
@@ -55,18 +85,22 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitPurchaseRequest({
|
const payload = { items, reason: form.reason || undefined, priority: form.priority }
|
||||||
items,
|
const action = editingId
|
||||||
reason: form.reason || undefined,
|
? portalAPI.updatePurchaseRequest(editingId, payload)
|
||||||
priority: form.priority,
|
: portalAPI.submitPurchaseRequest(payload)
|
||||||
})
|
action
|
||||||
.then((pr) => {
|
.then((pr) => {
|
||||||
setRequests((prev) => [pr, ...prev])
|
if (editingId) {
|
||||||
|
setRequests((prev) => prev.map((r) => (r.id === editingId ? pr : r)))
|
||||||
|
} else {
|
||||||
|
setRequests((prev) => [pr, ...prev])
|
||||||
|
}
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
resetForm()
|
||||||
toast.success('تم إرسال طلب الشراء')
|
toast.success(editingId ? 'تم تعديل الطلب' : 'تم إرسال طلب الشراء')
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
.catch((err: any) => toast.error(err?.response?.data?.message || 'فشل إرسال الطلب'))
|
||||||
.finally(() => setSubmitting(false))
|
.finally(() => setSubmitting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +111,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => { resetForm(); setShowModal(true) }}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -121,9 +155,17 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
{pr.status === 'PENDING' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => openEdit(pr)} className="text-xs text-teal-600 hover:underline">تعديل</button>
|
||||||
|
<button type="button" onClick={() => handleDelete(pr.id)} className="text-xs text-red-600 hover:underline">حذف</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -131,7 +173,11 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => { setShowModal(false); resetForm() }}
|
||||||
|
title={editingId ? 'تعديل طلب الشراء' : 'طلب شراء جديد'}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
@@ -200,7 +246,7 @@ export default function PortalPurchaseRequestsPage() {
|
|||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : (editingId ? 'حفظ التعديل' : 'إرسال الطلب')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { useParams} from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +25,6 @@ import Modal from '@/components/Modal'
|
|||||||
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
|
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
|
||||||
import { contactsAPI } from '@/lib/api/contacts'
|
import { contactsAPI } from '@/lib/api/contacts'
|
||||||
import { pipelinesAPI } from '@/lib/api/pipelines'
|
import { pipelinesAPI } from '@/lib/api/pipelines'
|
||||||
import { employeesAPI } from '@/lib/api/employees'
|
|
||||||
import { useLanguage } from '@/contexts/LanguageContext'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
|
|
||||||
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -34,7 +34,26 @@ const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
|||||||
PREPARE_TO_BID: 'Prepare to bid',
|
PREPARE_TO_BID: 'Prepare to bid',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDisplayFileName = (attachment: any) => {
|
||||||
|
const name = String(attachment.originalName || attachment.fileName || 'file')
|
||||||
|
|
||||||
|
if (!/[ÃÄÅØÙ]/.test(name)) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(
|
||||||
|
Array.from(name, (char: string) => char.charCodeAt(0) & 0xff)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new TextDecoder('utf-8').decode(bytes)
|
||||||
|
} catch {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function TenderDetailContent() {
|
function TenderDetailContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tenderId = params.id as string
|
const tenderId = params.id as string
|
||||||
@@ -43,7 +62,12 @@ function TenderDetailContent() {
|
|||||||
const [tender, setTender] = useState<Tender | null>(null)
|
const [tender, setTender] = useState<Tender | null>(null)
|
||||||
const [history, setHistory] = useState<any[]>([])
|
const [history, setHistory] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'directives' | 'attachments' | 'history'>('info')
|
type TenderTab = 'info' | 'directives' | 'attachments' | 'history'
|
||||||
|
const [activeTab, setActiveTab] = useState<TenderTab>('info')
|
||||||
|
const openTab = (tab: TenderTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(`/tenders/${params.id}?tab=${tab}`)
|
||||||
|
}
|
||||||
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
|
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
|
||||||
const [showConvertModal, setShowConvertModal] = useState(false)
|
const [showConvertModal, setShowConvertModal] = useState(false)
|
||||||
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
|
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
|
||||||
@@ -63,10 +87,13 @@ function TenderDetailContent() {
|
|||||||
const [completeNotes, setCompleteNotes] = useState('')
|
const [completeNotes, setCompleteNotes] = useState('')
|
||||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
const directiveFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
|
||||||
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
|
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
|
||||||
|
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
|
||||||
|
const termsInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const costInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const offersInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fetchTender = async () => {
|
const fetchTender = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -85,6 +112,15 @@ function TenderDetailContent() {
|
|||||||
setHistory(data)
|
setHistory(data)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get('tab') as TenderTab | null
|
||||||
|
const allowedTabs: TenderTab[] = ['info', 'directives', 'attachments', 'history']
|
||||||
|
if (tabParam && allowedTabs.includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTender()
|
fetchTender()
|
||||||
@@ -100,9 +136,12 @@ function TenderDetailContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showDirectiveModal || showConvertModal) {
|
if (showDirectiveModal || showConvertModal) {
|
||||||
employeesAPI
|
// Use the directive-scoped employee list so non-HR users (with
|
||||||
.getAll({ status: 'ACTIVE', pageSize: 500 })
|
// tenders:directives:create) can populate this dropdown without
|
||||||
.then((r: any) => setEmployees(r.employees || []))
|
// being granted hr:employees:read (which would leak salaries etc.).
|
||||||
|
tendersAPI
|
||||||
|
.getAssignableEmployees()
|
||||||
|
.then((list) => setEmployees(list))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,19 +216,42 @@ function TenderDetailContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTenderFileUpload = async (
|
||||||
const file = e.target.files?.[0]
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
if (!file) return
|
category?: string,
|
||||||
|
) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
if (!files.length) return
|
||||||
|
|
||||||
|
if (category) setUploadingCategory(category)
|
||||||
|
else setSubmitting(true)
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
// Upload files sequentially so a failure of one file doesn't break the rest.
|
||||||
toast.success(t('tenders.uploadFile'))
|
for (const file of files) {
|
||||||
fetchTender()
|
try {
|
||||||
} catch (err: any) {
|
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
|
||||||
toast.error(err.response?.data?.message || 'Upload failed')
|
successCount++
|
||||||
|
} catch (err: any) {
|
||||||
|
failCount++
|
||||||
|
const msg = err.response?.data?.message || 'Upload failed'
|
||||||
|
toast.error(`${file.name}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(
|
||||||
|
files.length === 1
|
||||||
|
? t('tenders.uploadFile')
|
||||||
|
: `${successCount}/${files.length} ✓`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (successCount > 0) fetchTender()
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
|
setUploadingCategory(null)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,20 +262,35 @@ function TenderDetailContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const files = Array.from(e.target.files || [])
|
||||||
const directiveId = directiveIdForUpload
|
const directiveId = directiveIdForUpload
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
setDirectiveIdForUpload(null)
|
setDirectiveIdForUpload(null)
|
||||||
|
|
||||||
if (!file || !directiveId) return
|
if (!files.length || !directiveId) return
|
||||||
|
|
||||||
setUploadingDirectiveId(directiveId)
|
setUploadingDirectiveId(directiveId)
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
for (const file of files) {
|
||||||
toast.success(t('tenders.uploadFile'))
|
try {
|
||||||
fetchTender()
|
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||||
} catch (err: any) {
|
successCount++
|
||||||
toast.error(err.response?.data?.message || 'Upload failed')
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Upload failed'
|
||||||
|
toast.error(`${file.name}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(
|
||||||
|
files.length === 1
|
||||||
|
? t('tenders.uploadFile')
|
||||||
|
: `${successCount}/${files.length} ✓`
|
||||||
|
)
|
||||||
|
fetchTender()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDirectiveId(null)
|
setUploadingDirectiveId(null)
|
||||||
}
|
}
|
||||||
@@ -266,7 +343,7 @@ function TenderDetailContent() {
|
|||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => openTab(tab.id as TenderTab)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-indigo-100 text-indigo-800'
|
? 'bg-indigo-100 text-indigo-800'
|
||||||
@@ -428,6 +505,7 @@ function TenderDetailContent() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={directiveFileInputRef}
|
ref={directiveFileInputRef}
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDirectiveFileUpload}
|
onChange={handleDirectiveFileUpload}
|
||||||
/>
|
/>
|
||||||
@@ -455,65 +533,102 @@ function TenderDetailContent() {
|
|||||||
|
|
||||||
{activeTab === 'attachments' && (
|
{activeTab === 'attachments' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
{(() => {
|
||||||
<input
|
const all = (tender.attachments || []) as any[]
|
||||||
type="file"
|
const sections: Array<{
|
||||||
ref={fileInputRef}
|
key: string
|
||||||
className="hidden"
|
label: string
|
||||||
onChange={handleTenderFileUpload}
|
category: string
|
||||||
/>
|
ref: React.RefObject<HTMLInputElement>
|
||||||
<button
|
}> = [
|
||||||
onClick={() => fileInputRef.current?.click()}
|
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
|
||||||
disabled={submitting}
|
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
|
||||||
>
|
]
|
||||||
{submitting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t('tenders.uploadFile')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!tender.attachments?.length ? (
|
// Legacy attachments without a recognized category live under
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
// the dafter section by default so nothing gets hidden.
|
||||||
) : (
|
const knownCategories = new Set(sections.map((s) => s.category))
|
||||||
<ul className="space-y-2">
|
const inSection = (a: any, category: string) =>
|
||||||
{tender.attachments.map((a: any) => (
|
a.category === category ||
|
||||||
<li
|
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
|
||||||
key={a.id}
|
|
||||||
className="flex items-center justify-between border rounded px-3 py-2"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
{a.originalName || a.fileName}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
return (
|
||||||
onClick={async () => {
|
<div className="space-y-6">
|
||||||
if (!confirm('حذف الملف؟')) return
|
{sections.map((section) => {
|
||||||
try {
|
const items = all.filter((a) => inSection(a, section.category))
|
||||||
await tendersAPI.deleteAttachment(a.id)
|
const isUploading = uploadingCategory === section.category
|
||||||
toast.success('تم الحذف')
|
return (
|
||||||
fetchTender()
|
<div key={section.key} className="border rounded-lg p-4">
|
||||||
} catch {
|
<div className="flex items-center justify-between mb-3">
|
||||||
toast.error('فشل الحذف')
|
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
|
||||||
}
|
<div>
|
||||||
}}
|
<input
|
||||||
className="text-red-600 text-sm hover:underline"
|
type="file"
|
||||||
>
|
ref={section.ref}
|
||||||
حذف
|
multiple
|
||||||
</button>
|
className="hidden"
|
||||||
</li>
|
onChange={(e) => handleTenderFileUpload(e, section.category)}
|
||||||
))}
|
/>
|
||||||
</ul>
|
<button
|
||||||
)}
|
type="button"
|
||||||
|
onClick={() => section.ref.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('tenders.uploadFile')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((a: any) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between border rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{getDisplayFileName(a)}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('حذف الملف؟')) return
|
||||||
|
try {
|
||||||
|
await tendersAPI.deleteAttachment(a.id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
fetchTender()
|
||||||
|
} catch {
|
||||||
|
toast.error('فشل الحذف')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,17 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
|
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
|
||||||
import { categoriesAPI, Category } from '@/lib/api/categories'
|
import { categoriesAPI, Category } from '@/lib/api/categories'
|
||||||
|
import { filterContactCategoryTree } from '@/lib/supplierCategories'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
interface CategorySelectorProps {
|
interface CategorySelectorProps {
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
onChange: (selectedIds: string[]) => void
|
onChange: (selectedIds: string[]) => void
|
||||||
multiSelect?: boolean
|
multiSelect?: boolean
|
||||||
|
categoryFilter?: (category: Category) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategorySelector({ selectedIds, onChange, multiSelect = true }: CategorySelectorProps) {
|
export default function CategorySelector({ selectedIds, onChange, multiSelect = true, categoryFilter }: CategorySelectorProps) {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||||
@@ -25,11 +27,28 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
fetchCategories()
|
fetchCategories()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const filterCategoryTree = (items: Category[]): Category[] => {
|
||||||
|
if (!categoryFilter) return items
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((category) => {
|
||||||
|
const children = category.children ? filterCategoryTree(category.children) : []
|
||||||
|
const shouldShow = categoryFilter(category)
|
||||||
|
|
||||||
|
if (!shouldShow && children.length === 0) return null
|
||||||
|
|
||||||
|
return { ...category, children } as Category
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCategories = filterCategoryTree(categories)
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await categoriesAPI.getTree()
|
const data = await categoriesAPI.getTree()
|
||||||
setCategories(data)
|
setCategories(filterContactCategoryTree(data))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load categories')
|
toast.error('Failed to load categories')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,6 +121,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
{/* Expand/Collapse */}
|
{/* Expand/Collapse */}
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
toggleExpand(category.id)
|
toggleExpand(category.id)
|
||||||
@@ -127,6 +147,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
|
|
||||||
{/* Category Name */}
|
{/* Category Name */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => toggleSelect(category.id)}
|
onClick={() => toggleSelect(category.id)}
|
||||||
className="flex-1 text-left flex items-center gap-2"
|
className="flex-1 text-left flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -179,7 +200,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return selectedIds
|
return selectedIds
|
||||||
.map(id => findCategory(categories, id))
|
.map(id => findCategory(visibleCategories, id))
|
||||||
.filter(cat => cat !== null) as Category[]
|
.filter(cat => cat !== null) as Category[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +224,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
title="Add Category"
|
title="Add Category"
|
||||||
@@ -221,6 +243,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => removeSelected(category.id)}
|
onClick={() => removeSelected(category.id)}
|
||||||
className="hover:text-blue-900"
|
className="hover:text-blue-900"
|
||||||
>
|
>
|
||||||
@@ -233,11 +256,12 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
|
|
||||||
{/* Category Tree */}
|
{/* Category Tree */}
|
||||||
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
|
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
|
||||||
{categories.length === 0 ? (
|
{visibleCategories.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||||
<p>No categories found</p>
|
<p>No categories found</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
|
className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
|
||||||
>
|
>
|
||||||
@@ -245,7 +269,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
categories.map(category => renderCategory(category))
|
visibleCategories.map(category => renderCategory(category))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,7 +320,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="">None (Root Category)</option>
|
<option value="">None (Root Category)</option>
|
||||||
{categories.map(cat => (
|
{visibleCategories.map(cat => (
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -305,6 +329,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 mt-6">
|
<div className="flex items-center justify-end gap-3 mt-6">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAddModal(false)
|
setShowAddModal(false)
|
||||||
setNewCategoryName('')
|
setNewCategoryName('')
|
||||||
@@ -316,6 +341,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleAddCategory}
|
onClick={handleAddCategory}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
'UN',
|
'UN',
|
||||||
'NGO',
|
'NGO',
|
||||||
'INSTITUTION',
|
'INSTITUTION',
|
||||||
|
'SUPPLIER',
|
||||||
])
|
])
|
||||||
|
|
||||||
const isOrganizationType = organizationTypes.has(formData.type)
|
const isOrganizationType = organizationTypes.has(formData.type)
|
||||||
@@ -126,7 +127,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
||||||
const requiredFields = ['type', 'name', 'source', 'country']
|
const requiredFields = ['type', 'name', 'source', 'country']
|
||||||
|
|
||||||
// keep required fields as-is
|
// keep required fields as-is
|
||||||
@@ -223,6 +224,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
|
|||||||
<option value="UN">UN - الأمم المتحدة</option>
|
<option value="UN">UN - الأمم المتحدة</option>
|
||||||
<option value="NGO">NGO - منظمة غير حكومية</option>
|
<option value="NGO">NGO - منظمة غير حكومية</option>
|
||||||
<option value="INSTITUTION">Institution - مؤسسة</option>
|
<option value="INSTITUTION">Institution - مؤسسة</option>
|
||||||
|
<option value="SUPPLIER">Supplier - مورّد</option>
|
||||||
</select>
|
</select>
|
||||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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
|
canDelete?: boolean
|
||||||
canExport?: boolean
|
canExport?: boolean
|
||||||
canApprove?: boolean
|
canApprove?: boolean
|
||||||
|
canMarkAsPaid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -37,7 +38,7 @@ interface AuthContextType {
|
|||||||
logout: () => void
|
logout: () => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve') => boolean
|
hasPermission: (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid') => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
canDelete: wildcard || p.actions?.includes('delete') || false,
|
canDelete: wildcard || p.actions?.includes('delete') || false,
|
||||||
canExport: wildcard || p.actions?.includes('export') || false,
|
canExport: wildcard || p.actions?.includes('export') || false,
|
||||||
canApprove: wildcard || p.actions?.includes('approve') || false,
|
canApprove: wildcard || p.actions?.includes('approve') || false,
|
||||||
|
canMarkAsPaid: wildcard || p.actions?.includes('mark-as-paid') || false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve'): boolean => {
|
const hasPermission = (module: string, action: 'view' | 'create' | 'edit' | 'delete' | 'export' | 'approve' | 'mark-as-paid'): boolean => {
|
||||||
if (!user?.role?.permissions) return false
|
if (!user?.role?.permissions) return false
|
||||||
|
|
||||||
const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase())
|
const permission = user.role.permissions.find(p => p.module.toLowerCase() === module.toLowerCase())
|
||||||
@@ -160,7 +162,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
edit: 'canEdit',
|
edit: 'canEdit',
|
||||||
delete: 'canDelete',
|
delete: 'canDelete',
|
||||||
export: 'canExport',
|
export: 'canExport',
|
||||||
approve: 'canApprove'
|
approve: 'canApprove',
|
||||||
|
'mark-as-paid': 'canMarkAsPaid'
|
||||||
}
|
}
|
||||||
|
|
||||||
return permission[actionMap[action] as keyof Permission] as boolean
|
return permission[actionMap[action] as keyof Permission] as boolean
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ const translations = {
|
|||||||
view: 'عرض',
|
view: 'عرض',
|
||||||
win: 'فوز',
|
win: 'فوز',
|
||||||
lose: 'خسارة',
|
lose: 'خسارة',
|
||||||
archive: 'أرشفة',
|
delete: 'حذف',
|
||||||
deleteDeal: 'حذف الصفقة',
|
deleteDeal: 'حذف الصفقة',
|
||||||
markWon: 'تحديد كفائز',
|
markWon: 'تحديد كفائز',
|
||||||
markLost: 'تحديد كخاسر',
|
markLost: 'تحديد كخاسر',
|
||||||
@@ -563,7 +563,7 @@ const translations = {
|
|||||||
updateSuccess: 'تم تحديث الصفقة بنجاح',
|
updateSuccess: 'تم تحديث الصفقة بنجاح',
|
||||||
winSuccess: 'تم الفوز بالصفقة بنجاح',
|
winSuccess: 'تم الفوز بالصفقة بنجاح',
|
||||||
loseSuccess: 'تم تحديد الصفقة كخاسرة',
|
loseSuccess: 'تم تحديد الصفقة كخاسرة',
|
||||||
deleteSuccess: 'تم أرشفة الصفقة بنجاح',
|
deleteSuccess: 'تم حذف الصفقة بنجاح',
|
||||||
fixFormErrors: 'يرجى إصلاح أخطاء النموذج',
|
fixFormErrors: 'يرجى إصلاح أخطاء النموذج',
|
||||||
pipelineRequired: 'مسار المبيعات مطلوب',
|
pipelineRequired: 'مسار المبيعات مطلوب',
|
||||||
dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',
|
dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ export const dashboardAPI = {
|
|||||||
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const notificationsAPI = {
|
||||||
|
getMy: (params?: { page?: number; pageSize?: number }) =>
|
||||||
|
api.get('/notifications/my', { params }),
|
||||||
|
|
||||||
|
getUnreadCount: () =>
|
||||||
|
api.get('/notifications/unread-count'),
|
||||||
|
|
||||||
|
markAsRead: (id: string) =>
|
||||||
|
api.patch(`/notifications/${id}/read`),
|
||||||
|
|
||||||
|
markAllAsRead: () =>
|
||||||
|
api.patch('/notifications/read-all'),
|
||||||
|
}
|
||||||
|
|
||||||
export const crmAPI = {
|
export const crmAPI = {
|
||||||
// Deals
|
// Deals
|
||||||
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface ContactFilters {
|
|||||||
rating?: number
|
rating?: number
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
excludeSuppliers?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactsResponse {
|
export interface ContactsResponse {
|
||||||
@@ -93,6 +94,7 @@ export const contactsAPI = {
|
|||||||
if (filters.rating) params.append('rating', filters.rating.toString())
|
if (filters.rating) params.append('rating', filters.rating.toString())
|
||||||
if (filters.page) params.append('page', filters.page.toString())
|
if (filters.page) params.append('page', filters.page.toString())
|
||||||
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
|
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
|
||||||
|
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
|
||||||
|
|
||||||
const response = await api.get(`/contacts?${params.toString()}`)
|
const response = await api.get(`/contacts?${params.toString()}`)
|
||||||
const { data, pagination } = response.data
|
const { data, pagination } = response.data
|
||||||
@@ -154,6 +156,7 @@ export const contactsAPI = {
|
|||||||
if (filters.status) params.append('status', filters.status)
|
if (filters.status) params.append('status', filters.status)
|
||||||
if (filters.category) params.append('category', filters.category)
|
if (filters.category) params.append('category', filters.category)
|
||||||
if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
|
if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
|
||||||
|
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
|
||||||
|
|
||||||
const response = await api.get(`/contacts/export?${params.toString()}`, {
|
const response = await api.get(`/contacts/export?${params.toString()}`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PortalProfile {
|
|||||||
activeLoansCount: number
|
activeLoansCount: number
|
||||||
pendingLeavesCount: number
|
pendingLeavesCount: number
|
||||||
pendingPurchaseRequestsCount: number
|
pendingPurchaseRequestsCount: number
|
||||||
|
pendingExpenseClaimsCount: number
|
||||||
leaveBalance: Array<{
|
leaveBalance: Array<{
|
||||||
leaveType: string
|
leaveType: string
|
||||||
totalDays: number
|
totalDays: number
|
||||||
@@ -98,6 +99,53 @@ export interface PurchaseRequest {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExpenseClaim {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
claimNumber: string;
|
||||||
|
|
||||||
|
items?: ExpenseClaimItem[] | null;
|
||||||
|
totalAmount?: number | null;
|
||||||
|
|
||||||
|
expenseDate: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
description: string | null;
|
||||||
|
projectOrTender: string | null;
|
||||||
|
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'REJECTED' | string;
|
||||||
|
approvedBy?: string | null;
|
||||||
|
approvedAt?: string | null;
|
||||||
|
rejectedReason?: string | null;
|
||||||
|
approvalNote?: string | null;
|
||||||
|
isPaid?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
}> | null;
|
||||||
|
employee?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
uniqueEmployeeId?: string | null;
|
||||||
|
reportingToId?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseClaimItem {
|
||||||
|
expenseDate: string;
|
||||||
|
amount: number;
|
||||||
|
entityName?: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
@@ -143,6 +191,17 @@ export interface Salary {
|
|||||||
|
|
||||||
export const portalAPI = {
|
export const portalAPI = {
|
||||||
|
|
||||||
|
viewExpenseClaimAttachment: async (attachmentId: string): Promise<Blob> => {
|
||||||
|
const response = await api.get(
|
||||||
|
`/hr/portal/expense-claims/attachments/${attachmentId}/view`,
|
||||||
|
{
|
||||||
|
responseType: 'blob',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
|
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
if (status && status !== 'all') q.append('status', status)
|
if (status && status !== 'all') q.append('status', status)
|
||||||
@@ -175,6 +234,18 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateLoanRequest: async (
|
||||||
|
id: string,
|
||||||
|
data: { type?: string; amount?: number; installments?: number; reason?: string }
|
||||||
|
): Promise<Loan> => {
|
||||||
|
const response = await api.put(`/hr/portal/loans/${id}`, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLoanRequest: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/hr/portal/loans/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/overtime-requests')
|
const response = await api.get('/hr/portal/overtime-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -189,6 +260,18 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateOvertimeRequest: async (
|
||||||
|
attendanceId: string,
|
||||||
|
data: { hours?: number; reason?: string }
|
||||||
|
): Promise<PortalOvertimeRequest> => {
|
||||||
|
const response = await api.put(`/hr/portal/overtime-requests/${attendanceId}`, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteOvertimeRequest: async (attendanceId: string): Promise<void> => {
|
||||||
|
await api.delete(`/hr/portal/overtime-requests/${attendanceId}`)
|
||||||
|
},
|
||||||
|
|
||||||
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/managed-overtime-requests')
|
const response = await api.get('/hr/portal/managed-overtime-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -220,11 +303,39 @@ export const portalAPI = {
|
|||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
},
|
},
|
||||||
|
|
||||||
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => {
|
submitLeaveRequest: async (data: {
|
||||||
const response = await api.post('/hr/portal/leaves', data)
|
leaveType: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
leaveDate?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
reason?: string
|
||||||
|
}): Promise<Leave> => {
|
||||||
|
const response = await api.post('/hr/portal/leaves', data)
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateLeaveRequest: async (
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
leaveType?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
leaveDate?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
): Promise<Leave> => {
|
||||||
|
const response = await api.put(`/hr/portal/leaves/${id}`, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLeaveRequest: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/hr/portal/leaves/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
|
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
|
||||||
const response = await api.get('/hr/portal/purchase-requests')
|
const response = await api.get('/hr/portal/purchase-requests')
|
||||||
return response.data.data || []
|
return response.data.data || []
|
||||||
@@ -235,6 +346,126 @@ export const portalAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updatePurchaseRequest: async (
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
items?: Array<{ description: string; quantity?: number; estimatedPrice?: number }>
|
||||||
|
reason?: string
|
||||||
|
priority?: string
|
||||||
|
}
|
||||||
|
): Promise<PurchaseRequest> => {
|
||||||
|
const response = await api.put(`/hr/portal/purchase-requests/${id}`, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePurchaseRequest: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/hr/portal/purchase-requests/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpenseClaims: async (): Promise<ExpenseClaim[]> => {
|
||||||
|
const response = await api.get('/hr/portal/expense-claims')
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
submitExpenseClaim: async (data: {
|
||||||
|
items: Array<{
|
||||||
|
expenseDate: string;
|
||||||
|
amount: number;
|
||||||
|
entityName?: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
attachments?: File[];
|
||||||
|
}): Promise<ExpenseClaim> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('items', JSON.stringify(data.items));
|
||||||
|
|
||||||
|
if (data.description) {
|
||||||
|
formData.append('description', data.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
|
for (const file of data.attachments) {
|
||||||
|
formData.append('attachments', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post('/hr/portal/expense-claims', formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateExpenseClaim: async (
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
items: Array<{
|
||||||
|
expenseDate: string;
|
||||||
|
amount: number;
|
||||||
|
entityName?: string;
|
||||||
|
description: string;
|
||||||
|
projectOrTender?: string;
|
||||||
|
proofRef?: string;
|
||||||
|
}>;
|
||||||
|
description?: string;
|
||||||
|
attachments?: File[];
|
||||||
|
removeAttachmentIds?: string[];
|
||||||
|
}
|
||||||
|
): Promise<ExpenseClaim> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('items', JSON.stringify(data.items));
|
||||||
|
if (data.description) formData.append('description', data.description);
|
||||||
|
if (data.removeAttachmentIds && data.removeAttachmentIds.length > 0) {
|
||||||
|
formData.append('removeAttachmentIds', JSON.stringify(data.removeAttachmentIds));
|
||||||
|
}
|
||||||
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
|
for (const file of data.attachments) formData.append('attachments', file);
|
||||||
|
}
|
||||||
|
const response = await api.put(`/hr/portal/expense-claims/${id}`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteExpenseClaim: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/hr/portal/expense-claims/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getManagedExpenseClaims: async (
|
||||||
|
status?: string,
|
||||||
|
search?: string,
|
||||||
|
paid?: 'all' | 'paid' | 'unpaid',
|
||||||
|
): Promise<ExpenseClaim[]> => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (status && status !== 'all') q.append('status', status)
|
||||||
|
if (search && search.trim()) q.append('search', search.trim())
|
||||||
|
if (paid && paid !== 'all') q.append('paid', paid)
|
||||||
|
const response = await api.get(`/hr/portal/managed-expense-claims?${q.toString()}`)
|
||||||
|
return response.data.data || []
|
||||||
|
},
|
||||||
|
|
||||||
|
approveManagedExpenseClaim: async (id: string, approvalNote?: string): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.post(
|
||||||
|
`/hr/portal/managed-expense-claims/${id}/approve`,
|
||||||
|
approvalNote?.trim() ? { approvalNote: approvalNote.trim() } : {},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectManagedExpenseClaim: async (id: string, rejectedReason: string): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.post(`/hr/portal/managed-expense-claims/${id}/reject`, { rejectedReason })
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
markExpenseClaimPaid: async (id: string, isPaid: boolean): Promise<ExpenseClaim> => {
|
||||||
|
const response = await api.patch(`/hr/portal/managed-expense-claims/${id}/paid`, { isPaid })
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (month) params.append('month', String(month))
|
if (month) params.append('month', String(month))
|
||||||
|
|||||||
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
|
name: string
|
||||||
nameAr?: string
|
nameAr?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
type?: string
|
||||||
status: string
|
status: string
|
||||||
|
priority?: string
|
||||||
|
progress?: number
|
||||||
startDate?: string
|
startDate?: string
|
||||||
endDate?: string
|
endDate?: string
|
||||||
budget?: number
|
budget?: number
|
||||||
|
estimatedCost?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectData {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
type: string
|
||||||
|
startDate: string
|
||||||
|
endDate?: string
|
||||||
|
estimatedCost?: number
|
||||||
|
status?: string
|
||||||
|
priority?: string
|
||||||
|
progress?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectData extends Partial<CreateProjectData> {}
|
||||||
|
|
||||||
export const projectsAPI = {
|
export const projectsAPI = {
|
||||||
getAll: async (): Promise<Project[]> => {
|
getAll: async (): Promise<Project[]> => {
|
||||||
const response = await api.get('/projects/projects')
|
const response = await api.get('/projects/projects')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
}
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateProjectData): Promise<Project> => {
|
||||||
|
const response = await api.post('/projects/projects', data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateProjectData): Promise<Project> => {
|
||||||
|
const response = await api.put(`/projects/projects/${id}`, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/projects/projects/${id}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../api'
|
|||||||
export interface Tender {
|
export interface Tender {
|
||||||
id: string
|
id: string
|
||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
|
issueNumber?: string | null
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export interface TenderDirective {
|
|||||||
|
|
||||||
export interface CreateTenderData {
|
export interface CreateTenderData {
|
||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
|
issueNumber?: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
@@ -167,12 +169,16 @@ export const tendersAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/tenders/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => {
|
uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (category) formData.append('category', category)
|
if (category) formData.append('category', category)
|
||||||
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
|
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': undefined as any },
|
||||||
})
|
})
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
@@ -182,7 +188,7 @@ export const tendersAPI = {
|
|||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
if (category) formData.append('category', category)
|
if (category) formData.append('category', category)
|
||||||
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
|
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': undefined as any },
|
||||||
})
|
})
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
@@ -205,4 +211,17 @@ export const tendersAPI = {
|
|||||||
const response = await api.get('/tenders/directive-type-values')
|
const response = await api.get('/tenders/directive-type-values')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
// Minimal employee list (id + names only) safe to call without
|
||||||
|
// hr:employees:read. Used only to populate the directive assignee dropdown.
|
||||||
|
getAssignableEmployees: async (): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
firstNameAr: string | null
|
||||||
|
lastNameAr: string | null
|
||||||
|
}>> => {
|
||||||
|
const response = await api.get('/tenders/assignable-employees')
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
}
|
||||||
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