addition expense claims
This commit is contained in:
@@ -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;
|
||||
@@ -200,6 +200,7 @@ model Employee {
|
||||
commissions Commission[]
|
||||
loans Loan[]
|
||||
purchaseRequests PurchaseRequest[]
|
||||
expenseClaims ExpenseClaim[]
|
||||
leaveEntitlements LeaveEntitlement[]
|
||||
employeeContracts EmployeeContract[]
|
||||
tenderDirectivesAssigned TenderDirective[]
|
||||
@@ -503,6 +504,34 @@ model PurchaseRequest {
|
||||
@@map("purchase_requests")
|
||||
}
|
||||
|
||||
model ExpenseClaim {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
|
||||
claimNumber String @unique
|
||||
|
||||
items Json?
|
||||
totalAmount Decimal? @db.Decimal(12, 2)
|
||||
|
||||
expenseDate DateTime? @db.Date
|
||||
amount Decimal? @db.Decimal(12, 2)
|
||||
description String?
|
||||
projectOrTender String?
|
||||
|
||||
status String @default("PENDING")
|
||||
approvedBy String?
|
||||
approvedAt DateTime?
|
||||
rejectedReason String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([employeeId])
|
||||
@@index([status])
|
||||
@@map("expense_claims")
|
||||
}
|
||||
|
||||
model LeaveEntitlement {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
|
||||
@@ -59,6 +59,27 @@ router.post('/portal/purchase-requests', portalController.submitPurchaseRequest)
|
||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||
router.get('/portal/salaries', portalController.getMySalaries);
|
||||
|
||||
router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
|
||||
router.post('/portal/expense-claims', portalController.submitExpenseClaim);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
|
||||
@@ -176,6 +176,58 @@ 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 data = await portalService.submitExpenseClaim(req.user?.employeeId, req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(data, 'تم إرسال كشف المصاريف - Expense claim submitted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveManagedExpenseClaim(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await portalService.approveManagedExpenseClaim(req.user?.employeeId, req.params.id, req.user!.id);
|
||||
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 getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||
|
||||
@@ -32,10 +32,11 @@ class PortalService {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, pendingExpenseClaims, leaveBalance] = await Promise.all([
|
||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||
prisma.expenseClaim.count({ where: { employeeId: empId, status: 'PENDING' } }),
|
||||
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
||||
]);
|
||||
|
||||
@@ -45,6 +46,7 @@ class PortalService {
|
||||
activeLoansCount: loansCount,
|
||||
pendingLeavesCount: pendingLeaves,
|
||||
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
||||
pendingExpenseClaimsCount: pendingExpenseClaims,
|
||||
leaveBalance,
|
||||
},
|
||||
};
|
||||
@@ -492,6 +494,296 @@ class PortalService {
|
||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
|
||||
private async generateExpenseClaimNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `EC-${year}-`;
|
||||
|
||||
const last = await prisma.expenseClaim.findFirst({
|
||||
where: {
|
||||
claimNumber: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
claimNumber: true,
|
||||
},
|
||||
});
|
||||
|
||||
let next = 1;
|
||||
if (last?.claimNumber) {
|
||||
const parts = last.claimNumber.split('-');
|
||||
next = parseInt(parts[2] || '0', 10) + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async getMyExpenseClaims(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
|
||||
return prisma.expenseClaim.findMany({
|
||||
where: { employeeId: empId },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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: [],
|
||||
});
|
||||
|
||||
return claim;
|
||||
}
|
||||
|
||||
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
|
||||
const where: any = {};
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
} else {
|
||||
where.status = 'PENDING';
|
||||
}
|
||||
|
||||
return prisma.expenseClaim.findMany({
|
||||
where,
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
reportingToId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async approveManagedExpenseClaim(
|
||||
managerEmployeeId: string | undefined,
|
||||
claimId: string,
|
||||
userId: 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,
|
||||
},
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await notificationsService.notifyEmployeeUser({
|
||||
employeeId: claim.employeeId,
|
||||
type: 'EXPENSE_CLAIM_APPROVED',
|
||||
title: 'تمت الموافقة على كشف المصاريف',
|
||||
message: `تمت الموافقة على كشف المصاريف الخاص بك برقم ${claim.claimNumber}.`,
|
||||
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 getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const now = new Date();
|
||||
|
||||
@@ -222,7 +222,7 @@ class NotificationsService {
|
||||
}
|
||||
|
||||
async resolveApprovalRecipients(params: {
|
||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
|
||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
|
||||
fallbackEmployeeId?: string;
|
||||
fallbackToManager?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
@@ -260,7 +260,7 @@ class NotificationsService {
|
||||
}
|
||||
|
||||
async notifyApprovalRecipients(params: {
|
||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
|
||||
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests' | 'expense_requests';
|
||||
fallbackEmployeeId?: string;
|
||||
fallbackToManager?: boolean;
|
||||
type: string;
|
||||
|
||||
Reference in New Issue
Block a user