Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp
This commit is contained in:
@@ -136,17 +136,15 @@ class AdminController {
|
||||
|
||||
async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const position = await adminService.createPosition(
|
||||
{
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
code: req.body.code,
|
||||
},
|
||||
userId
|
||||
);
|
||||
const position = await adminService.createPosition({
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.status(201).json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -155,15 +153,15 @@ class AdminController {
|
||||
|
||||
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const position = await adminService.updatePosition(
|
||||
req.params.id,
|
||||
{
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
},
|
||||
userId
|
||||
);
|
||||
const position = await adminService.updatePosition(req.params.id, {
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -182,15 +180,73 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async deletePosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
// ========== PERMISSION GROUPS (Phase 3) ==========
|
||||
|
||||
async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await adminService.deletePosition(req.params.id, userId);
|
||||
res.json(ResponseFormatter.success(null, 'Role deleted successfully'));
|
||||
const groups = await adminService.getPermissionGroups();
|
||||
res.json(ResponseFormatter.success(groups));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.createPermissionGroup(req.body);
|
||||
res.status(201).json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroup(req.params.id, req.body);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroupPermissions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroupPermissions(
|
||||
req.params.id,
|
||||
req.body.permissions
|
||||
);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRoles(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const roles = await adminService.getUserRoles(req.params.userId);
|
||||
res.json(ResponseFormatter.success(roles));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async assignUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userRole = await adminService.assignUserRole(req.params.userId, req.body.roleId);
|
||||
res.status(201).json(ResponseFormatter.success(userRole));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await adminService.removeUserRole(req.params.userId, req.params.roleId);
|
||||
res.json(ResponseFormatter.success({ success: true }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const adminController = new AdminController();
|
||||
export const adminController = new AdminController();
|
||||
|
||||
@@ -89,43 +89,33 @@ router.get(
|
||||
adminController.getPositions
|
||||
);
|
||||
|
||||
// Create role
|
||||
router.post(
|
||||
'/positions',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('title').notEmpty().trim(),
|
||||
body('titleAr').optional().isString().trim(),
|
||||
body('code').notEmpty().trim(),
|
||||
body('departmentId').isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
body('code').optional().isString().trim(),
|
||||
],
|
||||
validate,
|
||||
adminController.createPosition
|
||||
);
|
||||
|
||||
// Update role name (title/titleAr)
|
||||
router.put(
|
||||
'/positions/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('title').optional().notEmpty().trim(),
|
||||
body('titleAr').optional().isString().trim(),
|
||||
body('code').optional().notEmpty().trim(),
|
||||
body('departmentId').optional().isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePosition
|
||||
);
|
||||
|
||||
// Delete (soft delete) a role/position
|
||||
router.delete(
|
||||
'/positions/:id',
|
||||
authorize('admin', 'roles', 'delete'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
adminController.deletePosition
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/positions/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
@@ -137,4 +127,68 @@ router.put(
|
||||
adminController.updatePositionPermissions
|
||||
);
|
||||
|
||||
export default router;
|
||||
// ========== PERMISSION GROUPS (Phase 3 - multi-group) ==========
|
||||
|
||||
router.get(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'read'),
|
||||
adminController.getPermissionGroups
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('name').notEmpty().trim(),
|
||||
],
|
||||
validate,
|
||||
adminController.createPermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[param('id').isUUID()],
|
||||
validate,
|
||||
adminController.updatePermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('permissions').isArray(),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePermissionGroupPermissions
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'read'),
|
||||
[param('userId').isUUID()],
|
||||
validate,
|
||||
adminController.getUserRoles
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[
|
||||
param('userId').isUUID(),
|
||||
body('roleId').isUUID(),
|
||||
],
|
||||
validate,
|
||||
adminController.assignUserRole
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/roles/:roleId',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[param('userId').isUUID(), param('roleId').isUUID()],
|
||||
validate,
|
||||
adminController.removeUserRole
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -39,19 +39,6 @@ export interface AuditLogFilters {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionData {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePositionData {
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
}
|
||||
|
||||
class AdminService {
|
||||
// ========== USERS ==========
|
||||
|
||||
@@ -107,7 +94,7 @@ class AdminService {
|
||||
]);
|
||||
|
||||
const sanitized = users.map((u) => {
|
||||
const { password: _, ...rest } = u as any;
|
||||
const { password: _, ...rest } = u;
|
||||
return rest;
|
||||
});
|
||||
|
||||
@@ -137,7 +124,7 @@ class AdminService {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
const { password: _, ...rest } = user as any;
|
||||
const { password: _, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -236,7 +223,7 @@ class AdminService {
|
||||
...(data.email && { email: data.email }),
|
||||
...(data.username && { username: data.username }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive }),
|
||||
...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }),
|
||||
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
|
||||
};
|
||||
|
||||
if (data.password && data.password.length >= 8) {
|
||||
@@ -255,7 +242,7 @@ class AdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = user as any;
|
||||
const { password: _, ...sanitized } = user;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
@@ -286,7 +273,7 @@ class AdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = updated as any;
|
||||
const { password: _, ...sanitized } = updated;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
@@ -393,7 +380,7 @@ class AdminService {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
department: { select: { id: true, name: true, nameAr: true } },
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: {
|
||||
@@ -419,118 +406,100 @@ class AdminService {
|
||||
return withUserCount;
|
||||
}
|
||||
|
||||
private async generateUniqueCode(base: string) {
|
||||
const cleaned = (base || 'ROLE')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 18) || 'ROLE';
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const suffix = Math.floor(1000 + Math.random() * 9000);
|
||||
const code = `${cleaned}_${suffix}`;
|
||||
const exists = await prisma.position.findUnique({ where: { code } });
|
||||
if (!exists) return code;
|
||||
async createPosition(data: {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
code: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
|
||||
// fallback
|
||||
return `${cleaned}_${Date.now()}`;
|
||||
}
|
||||
|
||||
async createPosition(data: CreatePositionData, createdById: string) {
|
||||
const title = (data.title || '').trim();
|
||||
const titleAr = (data.titleAr || '').trim();
|
||||
|
||||
if (!title && !titleAr) {
|
||||
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
|
||||
}
|
||||
|
||||
const department = await prisma.department.findUnique({
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
|
||||
if (!department || !department.isActive) {
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
|
||||
let code = (data.code || '').trim();
|
||||
if (code) {
|
||||
code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_');
|
||||
const exists = await prisma.position.findUnique({ where: { code } });
|
||||
if (exists) {
|
||||
throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists');
|
||||
}
|
||||
} else {
|
||||
code = await this.generateUniqueCode(title || titleAr || 'ROLE');
|
||||
}
|
||||
|
||||
const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1;
|
||||
|
||||
const created = await prisma.position.create({
|
||||
return prisma.position.create({
|
||||
data: {
|
||||
title: title || titleAr,
|
||||
titleAr: titleAr || null,
|
||||
code,
|
||||
title: data.title,
|
||||
titleAr: data.titleAr,
|
||||
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
|
||||
departmentId: data.departmentId,
|
||||
level,
|
||||
level: data.level ?? 5,
|
||||
description: data.description,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: createdById,
|
||||
changes: {
|
||||
created: {
|
||||
title: created.title,
|
||||
titleAr: created.titleAr,
|
||||
code: created.code,
|
||||
departmentId: created.departmentId,
|
||||
level: created.level,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const all = await this.getPositions();
|
||||
return all.find((p: any) => p.id === created.id) || created;
|
||||
}
|
||||
|
||||
async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) {
|
||||
const existing = await prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!existing) {
|
||||
async updatePosition(
|
||||
positionId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
code?: string;
|
||||
departmentId?: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
) {
|
||||
const position = await prisma.position.findUnique({
|
||||
where: { id: positionId },
|
||||
});
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
}
|
||||
|
||||
const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title;
|
||||
const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || '');
|
||||
|
||||
const finalTitle = nextTitle || nextTitleAr;
|
||||
if (!finalTitle) {
|
||||
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
|
||||
if (data.code && data.code !== position.code) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.position.update({
|
||||
if (data.departmentId && data.departmentId !== position.departmentId) {
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.titleAr !== undefined) updateData.titleAr = data.titleAr;
|
||||
if (data.code !== undefined) updateData.code = data.code.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
if (data.departmentId !== undefined) updateData.departmentId = data.departmentId;
|
||||
if (data.level !== undefined) updateData.level = data.level;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
return prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: {
|
||||
title: finalTitle,
|
||||
titleAr: nextTitleAr ? nextTitleAr : null,
|
||||
data: updateData,
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: positionId,
|
||||
action: 'UPDATE',
|
||||
userId: updatedById,
|
||||
changes: {
|
||||
before: { title: existing.title, titleAr: existing.titleAr },
|
||||
after: { title: updated.title, titleAr: updated.titleAr },
|
||||
},
|
||||
});
|
||||
|
||||
const all = await this.getPositions();
|
||||
return all.find((p: any) => p.id === positionId) || updated;
|
||||
}
|
||||
|
||||
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
|
||||
@@ -554,59 +523,118 @@ class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPositions().then((pos: any) => pos.find((p: any) => p.id === positionId) || position);
|
||||
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a role (Position).
|
||||
* - Prevent deletion if the position is assigned to any employees.
|
||||
* - Clean up position permissions.
|
||||
*/
|
||||
async deletePosition(positionId: string, deletedById: string) {
|
||||
const position = await prisma.position.findUnique({
|
||||
where: { id: positionId },
|
||||
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
|
||||
|
||||
async getPermissionGroups() {
|
||||
return prisma.role.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
_count: { select: { employees: true } },
|
||||
permissions: true,
|
||||
_count: { select: { userRoles: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createPermissionGroup(data: { name: string; nameAr?: string; description?: string }) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
return prisma.role.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
description: data.description,
|
||||
},
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePermissionGroup(
|
||||
id: string,
|
||||
data: { name?: string; nameAr?: string; description?: string; isActive?: boolean }
|
||||
) {
|
||||
const role = await prisma.role.findUnique({ where: { id } });
|
||||
if (!role) {
|
||||
throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
}
|
||||
if (data.name && data.name !== role.name) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
}
|
||||
return prisma.role.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePermissionGroupPermissions(
|
||||
roleId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
) {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
if (permissions.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({
|
||||
roleId,
|
||||
module: p.module,
|
||||
resource: p.resource,
|
||||
actions: p.actions,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserRoles(userId: string) {
|
||||
return prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: { include: { permissions: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
async assignUserRole(userId: string, roleId: string) {
|
||||
const [user, role] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.role.findFirst({ where: { id: roleId, isActive: true } }),
|
||||
]);
|
||||
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
if (!role) throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
|
||||
const existing = await prisma.userRole.findUnique({
|
||||
where: { userId_roleId: { userId, roleId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'المستخدم منتمي بالفعل لهذه المجموعة - User already in group');
|
||||
}
|
||||
|
||||
if (position._count.employees > 0) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees'
|
||||
);
|
||||
return prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
include: { role: true },
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserRole(userId: string, roleId: string) {
|
||||
const deleted = await prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError(404, 'لم يتم العثور على الانتماء - User not in group');
|
||||
}
|
||||
|
||||
// Soft delete the position
|
||||
await prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
// Clean up permissions linked to this position
|
||||
await prisma.positionPermission.deleteMany({
|
||||
where: { positionId },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: positionId,
|
||||
action: 'DELETE',
|
||||
userId: deletedById,
|
||||
changes: {
|
||||
softDeleted: true,
|
||||
title: position.title,
|
||||
titleAr: position.titleAr,
|
||||
code: position.code,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService();
|
||||
export const adminService = new AdminService();
|
||||
|
||||
35
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
35
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Response } from 'express';
|
||||
import prisma from '../../config/database';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
class DashboardController {
|
||||
async getStats(req: AuthRequest, res: Response) {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
|
||||
prisma.contact.count(),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: { notIn: ['COMPLETED', 'CANCELLED'] },
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success({
|
||||
contacts: contactsCount,
|
||||
activeTasks: activeTasksCount,
|
||||
notifications: unreadNotificationsCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DashboardController();
|
||||
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal file
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../shared/middleware/auth';
|
||||
import dashboardController from './dashboard.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/stats', authenticate, dashboardController.getStats.bind(dashboardController));
|
||||
|
||||
export default router;
|
||||
@@ -19,15 +19,21 @@ export class HRController {
|
||||
|
||||
async findAllEmployees(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,
|
||||
departmentId: req.query.departmentId,
|
||||
status: req.query.status,
|
||||
};
|
||||
|
||||
const rawPage = parseInt(req.query.page as string, 10);
|
||||
const rawPageSize = parseInt(req.query.pageSize as string, 10);
|
||||
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
|
||||
const pageSize = Number.isNaN(rawPageSize) || rawPageSize < 1 || rawPageSize > 100 ? 20 : rawPageSize;
|
||||
|
||||
const rawSearch = req.query.search as string;
|
||||
const rawDepartmentId = req.query.departmentId as string;
|
||||
const rawStatus = req.query.status as string;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const filters: Record<string, string | undefined> = {};
|
||||
if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) filters.search = rawSearch.trim();
|
||||
if (rawDepartmentId && uuidRegex.test(rawDepartmentId)) filters.departmentId = rawDepartmentId;
|
||||
if (rawStatus && rawStatus !== 'all' && rawStatus.trim()) filters.status = rawStatus;
|
||||
|
||||
const result = await hrService.findAllEmployees(filters, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
@@ -92,6 +98,16 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { deviceId, records } = req.body;
|
||||
const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id);
|
||||
res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -112,6 +128,29 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -135,6 +174,42 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDepartmentsHierarchy(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tree = await hrService.getDepartmentsHierarchy();
|
||||
res.json(ResponseFormatter.success(tree));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.createDepartment(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(department, 'تم إضافة القسم بنجاح - Department created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.updateDepartment(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(department, 'تم تحديث القسم - Department updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await hrService.deleteDepartment(req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success({ success: true }, 'تم حذف القسم - Department deleted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -145,6 +220,198 @@ export class HRController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LOANS ==========
|
||||
|
||||
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loan = await hrService.findLoanById(req.params.id);
|
||||
res.json(ResponseFormatter.success(loan));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loan = await hrService.createLoan(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { startDate } = req.body;
|
||||
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { installmentId, paidDate } = req.body;
|
||||
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PURCHASE REQUESTS ==========
|
||||
|
||||
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.findPurchaseRequestById(req.params.id);
|
||||
res.json(ResponseFormatter.success(pr));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LEAVE ENTITLEMENTS ==========
|
||||
|
||||
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employeeId = req.params.employeeId || req.query.employeeId as string;
|
||||
const year = parseInt(req.query.year as string) || new Date().getFullYear();
|
||||
const balance = await hrService.getLeaveBalance(employeeId, year);
|
||||
res.json(ResponseFormatter.success(balance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
|
||||
res.json(ResponseFormatter.success(list));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const c = await hrService.findEmployeeContractById(req.params.id);
|
||||
res.json(ResponseFormatter.success(c));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||
const c = await hrService.createEmployeeContract(data, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
|
||||
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hrController = new HRController();
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { hrController } from './hr.controller';
|
||||
import { portalController } from './portal.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
||||
|
||||
router.get('/portal/me', portalController.getMe);
|
||||
router.get('/portal/loans', portalController.getMyLoans);
|
||||
router.post('/portal/loans', portalController.submitLoanRequest);
|
||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||
router.get('/portal/salaries', portalController.getMySalaries);
|
||||
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
@@ -19,11 +31,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'
|
||||
|
||||
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
|
||||
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
|
||||
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
|
||||
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
|
||||
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
|
||||
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
@@ -32,10 +47,44 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr
|
||||
// ========== DEPARTMENTS ==========
|
||||
|
||||
router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments);
|
||||
router.get('/departments/hierarchy', authorize('hr', 'all', 'read'), hrController.getDepartmentsHierarchy);
|
||||
router.post('/departments', authorize('hr', 'all', 'create'), hrController.createDepartment);
|
||||
router.put('/departments/:id', authorize('hr', 'all', 'update'), hrController.updateDepartment);
|
||||
router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController.deleteDepartment);
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
|
||||
|
||||
// ========== LOANS ==========
|
||||
|
||||
router.get('/loans', authorize('hr', 'all', 'read'), hrController.findAllLoans);
|
||||
router.get('/loans/:id', authorize('hr', 'all', 'read'), hrController.findLoanById);
|
||||
router.post('/loans', authorize('hr', 'all', 'create'), hrController.createLoan);
|
||||
router.post('/loans/:id/approve', authorize('hr', 'all', 'approve'), hrController.approveLoan);
|
||||
router.post('/loans/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectLoan);
|
||||
router.post('/loans/:id/pay-installment', authorize('hr', 'all', 'update'), hrController.recordLoanInstallmentPayment);
|
||||
|
||||
// ========== PURCHASE REQUESTS ==========
|
||||
|
||||
router.get('/purchase-requests', authorize('hr', 'all', 'read'), hrController.findAllPurchaseRequests);
|
||||
router.get('/purchase-requests/:id', authorize('hr', 'all', 'read'), hrController.findPurchaseRequestById);
|
||||
router.post('/purchase-requests', authorize('hr', 'all', 'create'), hrController.createPurchaseRequest);
|
||||
router.post('/purchase-requests/:id/approve', authorize('hr', 'all', 'approve'), hrController.approvePurchaseRequest);
|
||||
router.post('/purchase-requests/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectPurchaseRequest);
|
||||
|
||||
// ========== LEAVE ENTITLEMENTS ==========
|
||||
|
||||
router.get('/leave-balance/:employeeId', authorize('hr', 'all', 'read'), hrController.getLeaveBalance);
|
||||
router.get('/leave-entitlements', authorize('hr', 'all', 'read'), hrController.findAllLeaveEntitlements);
|
||||
router.post('/leave-entitlements', authorize('hr', 'all', 'create'), hrController.upsertLeaveEntitlement);
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
router.get('/contracts', authorize('hr', 'all', 'read'), hrController.findAllEmployeeContracts);
|
||||
router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEmployeeContractById);
|
||||
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
|
||||
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -5,14 +5,52 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
class HRService {
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
private normalizeEmployeeData(data: any): Record<string, any> {
|
||||
const toStr = (v: any) => (v != null && String(v).trim()) ? String(v).trim() : undefined;
|
||||
const toDate = (v: any) => {
|
||||
if (!v || !String(v).trim()) return undefined;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
};
|
||||
const toNum = (v: any) => (v != null && v !== '') ? Number(v) : undefined;
|
||||
|
||||
const raw: Record<string, any> = {
|
||||
firstName: toStr(data.firstName),
|
||||
lastName: toStr(data.lastName),
|
||||
firstNameAr: toStr(data.firstNameAr),
|
||||
lastNameAr: toStr(data.lastNameAr),
|
||||
email: toStr(data.email),
|
||||
phone: toStr(data.phone),
|
||||
mobile: toStr(data.mobile),
|
||||
dateOfBirth: toDate(data.dateOfBirth),
|
||||
gender: toStr(data.gender),
|
||||
nationality: toStr(data.nationality),
|
||||
nationalId: toStr(data.nationalId),
|
||||
employmentType: toStr(data.employmentType),
|
||||
contractType: toStr(data.contractType),
|
||||
hireDate: toDate(data.hireDate),
|
||||
departmentId: toStr(data.departmentId),
|
||||
positionId: toStr(data.positionId),
|
||||
reportingToId: toStr(data.reportingToId) || undefined,
|
||||
basicSalary: toNum(data.baseSalary ?? data.basicSalary) ?? 0,
|
||||
};
|
||||
return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
async createEmployee(data: any, userId: string) {
|
||||
const uniqueEmployeeId = await this.generateEmployeeId();
|
||||
const payload = this.normalizeEmployeeData(data);
|
||||
|
||||
if (!payload.firstName || !payload.lastName || !payload.email || !payload.mobile ||
|
||||
!payload.hireDate || !payload.departmentId || !payload.positionId) {
|
||||
throw new AppError(400, 'بيانات غير مكتملة - Missing required fields: firstName, lastName, email, mobile, hireDate, departmentId, positionId');
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId,
|
||||
...data,
|
||||
},
|
||||
...payload,
|
||||
} as any,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
@@ -132,9 +170,10 @@ class HRService {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const payload = this.normalizeEmployeeData(data);
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: payload,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
@@ -188,12 +227,74 @@ class HRService {
|
||||
|
||||
async recordAttendance(data: any, userId: string) {
|
||||
const attendance = await prisma.attendance.create({
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
sourceDeviceId: data.sourceDeviceId ?? undefined,
|
||||
externalId: data.externalId ?? undefined,
|
||||
rawData: data.rawData ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return attendance;
|
||||
}
|
||||
|
||||
async bulkSyncAttendanceFromDevice(deviceId: string, records: Array<{ employeePin: string; checkIn?: string; checkOut?: string; date: string }>, userId: string) {
|
||||
const results: { created: number; updated: number; skipped: number } = { created: 0, updated: 0, skipped: 0 };
|
||||
for (const rec of records) {
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ attendancePin: rec.employeePin },
|
||||
{ uniqueEmployeeId: rec.employeePin },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!emp) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
const date = new Date(rec.date);
|
||||
const existing = await prisma.attendance.findUnique({
|
||||
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||
});
|
||||
const checkIn = rec.checkIn ? new Date(rec.checkIn) : null;
|
||||
const checkOut = rec.checkOut ? new Date(rec.checkOut) : null;
|
||||
const workHours = checkIn && checkOut ? (checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60) : null;
|
||||
|
||||
if (existing) {
|
||||
await prisma.attendance.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
checkIn: checkIn ?? existing.checkIn,
|
||||
checkOut: checkOut ?? existing.checkOut,
|
||||
workHours: workHours ?? existing.workHours,
|
||||
status: rec.checkIn ? 'PRESENT' : existing.status,
|
||||
sourceDeviceId: deviceId,
|
||||
externalId: `${deviceId}-${emp.id}-${rec.date}`,
|
||||
rawData: rec as any,
|
||||
},
|
||||
});
|
||||
results.updated++;
|
||||
} else {
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
employeeId: emp.id,
|
||||
date,
|
||||
checkIn,
|
||||
checkOut,
|
||||
workHours,
|
||||
status: rec.checkIn ? 'PRESENT' : 'ABSENT',
|
||||
sourceDeviceId: deviceId,
|
||||
externalId: `${deviceId}-${emp.id}-${rec.date}`,
|
||||
rawData: rec as any,
|
||||
},
|
||||
});
|
||||
results.created++;
|
||||
}
|
||||
}
|
||||
await AuditLogger.log({ entityType: 'ATTENDANCE', entityId: deviceId, action: 'BULK_SYNC', userId, changes: results });
|
||||
return results;
|
||||
}
|
||||
|
||||
async getAttendance(employeeId: string, month: number, year: number) {
|
||||
return prisma.attendance.findMany({
|
||||
where: {
|
||||
@@ -212,10 +313,24 @@ class HRService {
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(data: any, userId: string) {
|
||||
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: data.leaveType } },
|
||||
});
|
||||
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: {
|
||||
...data,
|
||||
days: this.calculateLeaveDays(data.startDate, data.endDate),
|
||||
days,
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
@@ -239,12 +354,16 @@ class HRService {
|
||||
status: 'APPROVED',
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
rejectedReason: null,
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
const year = new Date(leave.startDate).getFullYear();
|
||||
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
@@ -255,6 +374,62 @@ class HRService {
|
||||
return leave;
|
||||
}
|
||||
|
||||
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||
const leave = await prisma.leave.findUnique({ where: { id } });
|
||||
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||
|
||||
const updated = await prisma.leave.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectedReason,
|
||||
approvedBy: null,
|
||||
approvedAt: null,
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: id,
|
||||
action: 'REJECT',
|
||||
userId,
|
||||
reason: rejectedReason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async findAllLeaves(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
|
||||
const [total, leaves] = await Promise.all([
|
||||
prisma.leave.count({ where }),
|
||||
prisma.leave.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { leaves, total, page, pageSize };
|
||||
}
|
||||
|
||||
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
||||
const ent = await prisma.leaveEntitlement.findUnique({
|
||||
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||
});
|
||||
if (ent) {
|
||||
await prisma.leaveEntitlement.update({
|
||||
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||
data: { usedDays: { increment: days } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(employeeId: string, month: number, year: number, userId: string) {
|
||||
@@ -341,6 +516,323 @@ class HRService {
|
||||
return salary;
|
||||
}
|
||||
|
||||
// ========== LOANS ==========
|
||||
|
||||
private async generateLoanNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `LN-${year}-`;
|
||||
const last = await prisma.loan.findFirst({
|
||||
where: { loanNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { loanNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.loanNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
const [total, loans] = await Promise.all([
|
||||
prisma.loan.count({ where }),
|
||||
prisma.loan.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { loans, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findLoanById(id: string) {
|
||||
const loan = await prisma.loan.findUnique({
|
||||
where: { id },
|
||||
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
|
||||
});
|
||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
return loan;
|
||||
}
|
||||
|
||||
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||
const loanNumber = await this.generateLoanNumber();
|
||||
const installments = data.installments || 1;
|
||||
const monthlyAmount = data.amount / installments;
|
||||
const loan = await prisma.loan.create({
|
||||
data: {
|
||||
loanNumber,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
amount: data.amount,
|
||||
installments,
|
||||
monthlyAmount,
|
||||
reason: data.reason,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||
return loan;
|
||||
}
|
||||
|
||||
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
||||
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
|
||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
|
||||
|
||||
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||
let d = new Date(startDate);
|
||||
for (let i = 1; i <= loan.installments; i++) {
|
||||
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
}
|
||||
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.loan.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
|
||||
}),
|
||||
...installments.map((inst) =>
|
||||
prisma.loanInstallment.create({
|
||||
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
|
||||
return this.findLoanById(id);
|
||||
}
|
||||
|
||||
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||
const loan = await prisma.loan.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', rejectedReason },
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
||||
return loan;
|
||||
}
|
||||
|
||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||
await prisma.loanInstallment.update({
|
||||
where: { id: installmentId },
|
||||
data: { status: 'PAID', paidDate },
|
||||
});
|
||||
const allPaid = (await prisma.loanInstallment.count({ where: { loanId, status: 'PENDING' } })) === 0;
|
||||
if (allPaid) {
|
||||
await prisma.loan.update({ where: { id: loanId }, data: { status: 'PAID_OFF' } });
|
||||
}
|
||||
await AuditLogger.log({ entityType: 'LOAN_INSTALLMENT', entityId: installmentId, action: 'PAY', userId });
|
||||
return this.findLoanById(loanId);
|
||||
}
|
||||
|
||||
// ========== PURCHASE REQUESTS ==========
|
||||
|
||||
private async generatePurchaseRequestNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `PR-${year}-`;
|
||||
const last = await prisma.purchaseRequest.findFirst({
|
||||
where: { requestNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { requestNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.requestNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async findAllPurchaseRequests(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
const [total, requests] = await Promise.all([
|
||||
prisma.purchaseRequest.count({ where }),
|
||||
prisma.purchaseRequest.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { purchaseRequests: requests, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findPurchaseRequestById(id: string) {
|
||||
const req = await prisma.purchaseRequest.findUnique({ where: { id }, include: { employee: true } });
|
||||
if (!req) throw new AppError(404, 'طلب الشراء غير موجود - Purchase request not found');
|
||||
return req;
|
||||
}
|
||||
|
||||
async createPurchaseRequest(data: { employeeId: string; items: any[]; reason?: string; priority?: string }, userId: string) {
|
||||
const requestNumber = await this.generatePurchaseRequestNumber();
|
||||
const totalAmount = Array.isArray(data.items)
|
||||
? data.items.reduce((s: number, i: any) => s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)), 0)
|
||||
: 0;
|
||||
const req = await prisma.purchaseRequest.create({
|
||||
data: {
|
||||
requestNumber,
|
||||
employeeId: data.employeeId,
|
||||
items: data.items,
|
||||
totalAmount,
|
||||
reason: data.reason,
|
||||
priority: data.priority || 'NORMAL',
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
|
||||
return req;
|
||||
}
|
||||
|
||||
async approvePurchaseRequest(id: string, approvedBy: string, userId: string) {
|
||||
const req = await prisma.purchaseRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
||||
return req;
|
||||
}
|
||||
|
||||
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
||||
const req = await prisma.purchaseRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', rejectedReason },
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
||||
return req;
|
||||
}
|
||||
|
||||
// ========== LEAVE ENTITLEMENTS ==========
|
||||
|
||||
async getLeaveBalance(employeeId: string, year: number) {
|
||||
const entitlements = await prisma.leaveEntitlement.findMany({
|
||||
where: { employeeId, year },
|
||||
});
|
||||
const approvedLeaves = await prisma.leave.findMany({
|
||||
where: { employeeId, status: 'APPROVED', startDate: { gte: new Date(year, 0, 1) }, endDate: { lte: new Date(year, 11, 31) } },
|
||||
});
|
||||
const usedByType: Record<string, number> = {};
|
||||
for (const l of approvedLeaves) {
|
||||
usedByType[l.leaveType] = (usedByType[l.leaveType] || 0) + l.days;
|
||||
}
|
||||
return entitlements.map((e) => ({
|
||||
leaveType: e.leaveType,
|
||||
totalDays: e.totalDays,
|
||||
carriedOver: e.carriedOver,
|
||||
usedDays: usedByType[e.leaveType] ?? e.usedDays,
|
||||
available: e.totalDays + e.carriedOver - (usedByType[e.leaveType] ?? e.usedDays),
|
||||
}));
|
||||
}
|
||||
|
||||
async findAllLeaveEntitlements(employeeId?: string, year?: number) {
|
||||
const where: any = {};
|
||||
if (employeeId) where.employeeId = employeeId;
|
||||
if (year) where.year = year;
|
||||
return prisma.leaveEntitlement.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true } } },
|
||||
orderBy: [{ employeeId: 'asc' }, { year: 'desc' }, { leaveType: 'asc' } ],
|
||||
});
|
||||
}
|
||||
|
||||
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||
const ent = await prisma.leaveEntitlement.upsert({
|
||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
|
||||
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
|
||||
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||
return ent;
|
||||
}
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
private async generateContractNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `ECT-${year}-`;
|
||||
const last = await prisma.employeeContract.findFirst({
|
||||
where: { contractNumber: { startsWith: prefix } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { contractNumber: true },
|
||||
});
|
||||
let next = 1;
|
||||
if (last) {
|
||||
const parts = last.contractNumber.split('-');
|
||||
next = parseInt(parts[2] || '0') + 1;
|
||||
}
|
||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async findAllEmployeeContracts(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||
const [total, contracts] = await Promise.all([
|
||||
prisma.employeeContract.count({ where }),
|
||||
prisma.employeeContract.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||
orderBy: { startDate: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { contracts, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findEmployeeContractById(id: string) {
|
||||
const c = await prisma.employeeContract.findUnique({ where: { id }, include: { employee: true } });
|
||||
if (!c) throw new AppError(404, 'العقد غير موجود - Contract not found');
|
||||
return c;
|
||||
}
|
||||
|
||||
async createEmployeeContract(data: { employeeId: string; type: string; startDate: Date; endDate?: Date; salary: number; documentUrl?: string; notes?: string }, userId: string) {
|
||||
const contractNumber = await this.generateContractNumber();
|
||||
const contract = await prisma.employeeContract.create({
|
||||
data: {
|
||||
contractNumber,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
salary: data.salary,
|
||||
documentUrl: data.documentUrl,
|
||||
notes: data.notes,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: contract.id, action: 'CREATE', userId });
|
||||
return contract;
|
||||
}
|
||||
|
||||
async updateEmployeeContract(id: string, data: { type?: string; endDate?: Date; salary?: number; documentUrl?: string; status?: string; notes?: string }, userId: string) {
|
||||
const contract = await prisma.employeeContract.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { employee: true },
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: id, action: 'UPDATE', userId });
|
||||
return contract;
|
||||
}
|
||||
|
||||
// ========== HELPERS ==========
|
||||
|
||||
private async generateEmployeeId(): Promise<string> {
|
||||
@@ -383,11 +875,112 @@ class HRService {
|
||||
async findAllDepartments() {
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
parent: { select: { id: true, name: true, nameAr: true } },
|
||||
_count: { select: { children: true, employees: true } }
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
return departments;
|
||||
}
|
||||
|
||||
async getDepartmentsHierarchy() {
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
parent: { select: { id: true, name: true, nameAr: true } },
|
||||
employees: {
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, firstName: true, lastName: true, firstNameAr: true, lastNameAr: true, position: { select: { title: true, titleAr: true } } }
|
||||
},
|
||||
positions: { select: { id: true, title: true, titleAr: true } },
|
||||
_count: { select: { children: true, employees: true } }
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const buildTree = (parentId: string | null): any[] =>
|
||||
departments
|
||||
.filter((d) => d.parentId === parentId)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
nameAr: d.nameAr,
|
||||
code: d.code,
|
||||
parentId: d.parentId,
|
||||
description: d.description,
|
||||
employees: d.employees,
|
||||
positions: d.positions,
|
||||
_count: d._count,
|
||||
children: buildTree(d.id)
|
||||
}));
|
||||
return buildTree(null);
|
||||
}
|
||||
|
||||
async createDepartment(data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }, userId: string) {
|
||||
const existing = await prisma.department.findUnique({ where: { code: data.code } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists');
|
||||
}
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.department.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) {
|
||||
throw new AppError(400, 'القسم الأب غير موجود - Parent department not found');
|
||||
}
|
||||
}
|
||||
const department = await prisma.department.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
code: data.code,
|
||||
parentId: data.parentId || null,
|
||||
description: data.description
|
||||
}
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: department.id, action: 'CREATE', userId, changes: { created: department } });
|
||||
return department;
|
||||
}
|
||||
|
||||
async updateDepartment(id: string, data: { name?: string; nameAr?: string; code?: string; parentId?: string; description?: string; isActive?: boolean }, userId: string) {
|
||||
const existing = await prisma.department.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
if (data.code && data.code !== existing.code) {
|
||||
const duplicate = await prisma.department.findUnique({ where: { code: data.code } });
|
||||
if (duplicate) {
|
||||
throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists');
|
||||
}
|
||||
}
|
||||
if (data.parentId === id) {
|
||||
throw new AppError(400, 'لا يمكن تعيين القسم كأب لنفسه - Department cannot be its own parent');
|
||||
}
|
||||
const department = await prisma.department.update({
|
||||
where: { id },
|
||||
data: { name: data.name, nameAr: data.nameAr, code: data.code, parentId: data.parentId ?? undefined, description: data.description, isActive: data.isActive }
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'UPDATE', userId, changes: { before: existing, after: department } });
|
||||
return department;
|
||||
}
|
||||
|
||||
async deleteDepartment(id: string, userId: string) {
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { children: true, employees: true } } }
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(404, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
if (dept._count.children > 0) {
|
||||
throw new AppError(400, 'لا يمكن حذف قسم يحتوي على أقسام فرعية - Cannot delete department with sub-departments');
|
||||
}
|
||||
if (dept._count.employees > 0) {
|
||||
throw new AppError(400, 'لا يمكن حذف قسم فيه موظفون - Cannot delete department with employees');
|
||||
}
|
||||
await prisma.department.delete({ where: { id } });
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'DELETE', userId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
async findAllPositions() {
|
||||
|
||||
106
backend/src/modules/hr/portal.controller.ts
Normal file
106
backend/src/modules/hr/portal.controller.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { portalService } from './portal.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class PortalController {
|
||||
async getMe(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await portalService.getMe(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMyLoans(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loans = await portalService.getMyLoans(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(loans));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitLoanRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loan = await portalService.submitLoanRequest(req.user?.employeeId, req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(loan, 'تم إرسال طلب القرض - Loan request submitted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMyLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const balance = await portalService.getMyLeaveBalance(req.user?.employeeId, year);
|
||||
res.json(ResponseFormatter.success(balance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMyLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const leaves = await portalService.getMyLeaves(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(leaves));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
startDate: new Date(req.body.startDate),
|
||||
endDate: new Date(req.body.endDate),
|
||||
};
|
||||
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const requests = await portalService.getMyPurchaseRequests(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(requests));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await portalService.submitPurchaseRequest(req.user?.employeeId, req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(pr, 'تم إرسال طلب الشراء - Purchase request submitted'));
|
||||
} 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;
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const attendance = await portalService.getMyAttendance(req.user?.employeeId, month, year);
|
||||
res.json(ResponseFormatter.success(attendance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMySalaries(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const salaries = await portalService.getMySalaries(req.user?.employeeId);
|
||||
res.json(ResponseFormatter.success(salaries));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const portalController = new PortalController();
|
||||
114
backend/src/modules/hr/portal.service.ts
Normal file
114
backend/src/modules/hr/portal.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { hrService } from './hr.service';
|
||||
|
||||
class PortalService {
|
||||
private requireEmployeeId(employeeId: string | undefined): string {
|
||||
if (!employeeId) {
|
||||
throw new AppError(403, 'يجب ربط المستخدم بموظف للوصول للبوابة - Employee link required for portal access');
|
||||
}
|
||||
return employeeId;
|
||||
}
|
||||
|
||||
async getMe(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: empId },
|
||||
select: {
|
||||
id: true,
|
||||
uniqueEmployeeId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
firstNameAr: true,
|
||||
lastNameAr: true,
|
||||
email: true,
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
position: { select: { title: true, titleAr: true } },
|
||||
},
|
||||
});
|
||||
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
|
||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, 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' } }),
|
||||
hrService.getLeaveBalance(empId, new Date().getFullYear()),
|
||||
]);
|
||||
|
||||
return {
|
||||
employee,
|
||||
stats: {
|
||||
activeLoansCount: loansCount,
|
||||
pendingLeavesCount: pendingLeaves,
|
||||
pendingPurchaseRequestsCount: pendingPurchaseRequests,
|
||||
leaveBalance,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getMyLoans(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return prisma.loan.findMany({
|
||||
where: { employeeId: empId },
|
||||
include: { installmentsList: { orderBy: { installmentNumber: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
|
||||
async getMyLeaveBalance(employeeId: string | undefined, year?: number) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const y = year || new Date().getFullYear();
|
||||
return hrService.getLeaveBalance(empId, y);
|
||||
}
|
||||
|
||||
async getMyLeaves(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return prisma.leave.findMany({
|
||||
where: { employeeId: empId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
|
||||
async getMyPurchaseRequests(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return prisma.purchaseRequest.findMany({
|
||||
where: { employeeId: empId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||
}
|
||||
|
||||
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const now = new Date();
|
||||
const m = month ?? now.getMonth() + 1;
|
||||
const y = year ?? now.getFullYear();
|
||||
return hrService.getAttendance(empId, m, y);
|
||||
}
|
||||
|
||||
async getMySalaries(employeeId: string | undefined) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
return prisma.salary.findMany({
|
||||
where: { employeeId: empId },
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
||||
take: 24,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const portalService = new PortalService();
|
||||
@@ -3,6 +3,7 @@ import adminRoutes from '../modules/admin/admin.routes';
|
||||
import authRoutes from '../modules/auth/auth.routes';
|
||||
import contactsRoutes from '../modules/contacts/contacts.routes';
|
||||
import crmRoutes from '../modules/crm/crm.routes';
|
||||
import dashboardRoutes from '../modules/dashboard/dashboard.routes';
|
||||
import hrRoutes from '../modules/hr/hr.routes';
|
||||
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||
import projectsRoutes from '../modules/projects/projects.routes';
|
||||
@@ -12,6 +13,7 @@ const router = Router();
|
||||
|
||||
// Module routes
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
|
||||
@@ -4,12 +4,47 @@ import { config } from '../../config';
|
||||
import { AppError } from './errorHandler';
|
||||
import prisma from '../../config/database';
|
||||
|
||||
export interface EffectivePermission {
|
||||
module: string;
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
function mergePermissions(
|
||||
positionPerms: { module: string; resource: string; actions: unknown }[],
|
||||
rolePerms: { module: string; resource: string; actions: unknown }[]
|
||||
): EffectivePermission[] {
|
||||
const key = (m: string, r: string) => `${m}:${r}`;
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
const add = (m: string, r: string, actions: unknown) => {
|
||||
const arr = Array.isArray(actions) ? actions : [];
|
||||
const actionSet = new Set<string>(arr.map(String));
|
||||
const k = key(m, r);
|
||||
const existing = map.get(k);
|
||||
if (existing) {
|
||||
actionSet.forEach((a) => existing.add(a));
|
||||
} else {
|
||||
map.set(k, actionSet);
|
||||
}
|
||||
};
|
||||
|
||||
(positionPerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
(rolePerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
|
||||
return Array.from(map.entries()).map(([k, actions]) => {
|
||||
const [module, resource] = k.split(':');
|
||||
return { module, resource, actions: Array.from(actions) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
employeeId?: string;
|
||||
employee?: any;
|
||||
effectivePermissions?: EffectivePermission[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +68,7 @@ export const authenticate = async (
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Get user with employee info
|
||||
// Get user with employee + roles (Phase 3: multi-group)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
include: {
|
||||
@@ -47,6 +82,14 @@ export const authenticate = async (
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
userRoles: {
|
||||
where: { role: { isActive: true } },
|
||||
include: {
|
||||
role: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,12 +102,18 @@ export const authenticate = async (
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
const positionPerms = user.employee?.position?.permissions ?? [];
|
||||
const rolePerms = (user as any).userRoles?.flatMap(
|
||||
(ur: any) => ur.role?.permissions ?? []
|
||||
) ?? [];
|
||||
const effectivePermissions = mergePermissions(positionPerms, rolePerms);
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
employeeId: user.employeeId || undefined,
|
||||
employee: user.employee,
|
||||
effectivePermissions,
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -76,25 +125,24 @@ export const authenticate = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Permission checking middleware
|
||||
// Permission checking middleware (Position + Role permissions merged)
|
||||
export const authorize = (module: string, resource: string, action: string) => {
|
||||
return async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user?.employee?.position?.permissions) {
|
||||
const perms = req.user?.effectivePermissions;
|
||||
if (!perms || perms.length === 0) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Find permission for this module and resource (check exact match or wildcard)
|
||||
const permission = req.user.employee.position.permissions.find(
|
||||
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
const permission = perms.find(
|
||||
(p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Check if action is allowed (check exact match or wildcard)
|
||||
const actions = permission.actions as string[];
|
||||
const actions = permission.actions;
|
||||
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
@@ -40,10 +40,12 @@ export const errorHandler = (
|
||||
|
||||
// Handle validation errors
|
||||
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||
const detail = process.env.NODE_ENV !== 'production' ? err.message : undefined;
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'بيانات غير صالحة - Invalid data',
|
||||
error: 'VALIDATION_ERROR',
|
||||
...(detail && { detail }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user