Admin panel: real data - backend API, users/audit/roles/stats, frontend wired
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -88,6 +88,16 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
// Admin permission for GM
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module: 'admin',
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Manager
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
|
||||
41
backend/scripts/add-admin-permission.ts
Normal file
41
backend/scripts/add-admin-permission.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Add admin permission for GM position.
|
||||
* Run this for existing databases where seed was run before admin module existed:
|
||||
* npx ts-node scripts/add-admin-permission.ts
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } });
|
||||
if (!gmPosition) {
|
||||
console.log('GM position not found. Run full seed first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = await prisma.positionPermission.findFirst({
|
||||
where: { positionId: gmPosition.id, module: 'admin' },
|
||||
});
|
||||
if (existing) {
|
||||
console.log('Admin permission already exists for GM.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module: 'admin',
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
console.log('Admin permission added for GM position.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
150
backend/src/modules/admin/admin.controller.ts
Normal file
150
backend/src/modules/admin/admin.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { adminService } from './admin.service';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
class AdminController {
|
||||
// ========== USERS ==========
|
||||
|
||||
async findAllUsers(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
search: req.query.search as string | undefined,
|
||||
isActive: req.query.status === 'active' ? true : req.query.status === 'inactive' ? false : undefined,
|
||||
positionId: req.query.positionId as string | undefined,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
pageSize: parseInt(req.query.pageSize as string) || 20,
|
||||
};
|
||||
const result = await adminService.findAllUsers(filters);
|
||||
res.json(ResponseFormatter.paginated(result.users, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findUserById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const user = await adminService.findUserById(req.params.id);
|
||||
res.json(ResponseFormatter.success(user));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const user = await adminService.createUser(
|
||||
{
|
||||
email: req.body.email,
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
employeeId: req.body.employeeId,
|
||||
isActive: req.body.isActive ?? true,
|
||||
},
|
||||
userId
|
||||
);
|
||||
res.status(201).json(ResponseFormatter.success(user));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const user = await adminService.updateUser(
|
||||
req.params.id,
|
||||
{
|
||||
email: req.body.email,
|
||||
username: req.body.username,
|
||||
password: req.body.password,
|
||||
employeeId: req.body.employeeId,
|
||||
isActive: req.body.isActive,
|
||||
},
|
||||
userId
|
||||
);
|
||||
res.json(ResponseFormatter.success(user));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleUserActive(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const user = await adminService.toggleUserActive(req.params.id, userId);
|
||||
res.json(ResponseFormatter.success(user));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await adminService.deleteUser(req.params.id, userId);
|
||||
res.json(ResponseFormatter.success(null, 'User deactivated successfully'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== AUDIT LOGS ==========
|
||||
|
||||
async getAuditLogs(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
entityType: req.query.entityType as string | undefined,
|
||||
action: req.query.action as string | undefined,
|
||||
userId: req.query.userId as string | undefined,
|
||||
startDate: req.query.startDate as string | undefined,
|
||||
endDate: req.query.endDate as string | undefined,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
pageSize: parseInt(req.query.pageSize as string) || 20,
|
||||
};
|
||||
const result = await adminService.getAuditLogs(filters);
|
||||
res.json(
|
||||
ResponseFormatter.paginated(result.logs, result.total, result.page, result.pageSize)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STATS ==========
|
||||
|
||||
async getStats(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await adminService.getStats();
|
||||
res.json(ResponseFormatter.success(stats));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POSITIONS (ROLES) ==========
|
||||
|
||||
async getPositions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const positions = await adminService.getPositions();
|
||||
res.json(ResponseFormatter.success(positions));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePositionPermissions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const position = await adminService.updatePositionPermissions(
|
||||
req.params.id,
|
||||
req.body.permissions
|
||||
);
|
||||
res.json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const adminController = new AdminController();
|
||||
103
backend/src/modules/admin/admin.routes.ts
Normal file
103
backend/src/modules/admin/admin.routes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { adminController } from './admin.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// ========== USERS ==========
|
||||
|
||||
router.get(
|
||||
'/users',
|
||||
authorize('admin', 'users', 'read'),
|
||||
adminController.findAllUsers
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:id',
|
||||
authorize('admin', 'users', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
adminController.findUserById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/users',
|
||||
authorize('admin', 'users', 'create'),
|
||||
[
|
||||
body('email').isEmail(),
|
||||
body('username').notEmpty().trim(),
|
||||
body('password').isLength({ min: 8 }),
|
||||
body('employeeId').isUUID(),
|
||||
],
|
||||
validate,
|
||||
adminController.createUser
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/users/:id',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('email').optional().isEmail(),
|
||||
body('username').optional().notEmpty().trim(),
|
||||
body('password').optional().isLength({ min: 8 }),
|
||||
],
|
||||
validate,
|
||||
adminController.updateUser
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/users/:id/toggle-active',
|
||||
authorize('admin', 'users', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
adminController.toggleUserActive
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:id',
|
||||
authorize('admin', 'users', 'delete'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
adminController.deleteUser
|
||||
);
|
||||
|
||||
// ========== AUDIT LOGS ==========
|
||||
|
||||
router.get(
|
||||
'/audit-logs',
|
||||
authorize('admin', 'audit-logs', 'read'),
|
||||
adminController.getAuditLogs
|
||||
);
|
||||
|
||||
// ========== STATS ==========
|
||||
|
||||
router.get(
|
||||
'/stats',
|
||||
authorize('admin', 'stats', 'read'),
|
||||
adminController.getStats
|
||||
);
|
||||
|
||||
// ========== POSITIONS (ROLES) ==========
|
||||
|
||||
router.get(
|
||||
'/positions',
|
||||
authorize('admin', 'roles', 'read'),
|
||||
adminController.getPositions
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/positions/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('permissions').isArray(),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePositionPermissions
|
||||
);
|
||||
|
||||
export default router;
|
||||
434
backend/src/modules/admin/admin.service.ts
Normal file
434
backend/src/modules/admin/admin.service.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { config } from '../../config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export interface UserFilters {
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
positionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
employeeId: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
employeeId?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
class AdminService {
|
||||
// ========== USERS ==========
|
||||
|
||||
async findAllUsers(filters: UserFilters) {
|
||||
const page = filters.page || 1;
|
||||
const pageSize = Math.min(filters.pageSize || 20, 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
if (filters.positionId) {
|
||||
where.employee = {
|
||||
positionId: filters.positionId,
|
||||
};
|
||||
}
|
||||
|
||||
if (filters.search?.trim()) {
|
||||
const search = filters.search.trim().toLowerCase();
|
||||
where.OR = [
|
||||
{ username: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{
|
||||
employee: {
|
||||
OR: [
|
||||
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: { select: { id: true, title: true, titleAr: true } },
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const sanitized = users.map((u) => {
|
||||
const { password: _, ...rest } = u;
|
||||
return rest;
|
||||
});
|
||||
|
||||
return {
|
||||
users: sanitized,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: { include: { permissions: true } },
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
const { password: _, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserData, createdById: string) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: data.employeeId },
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(400, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
if (employee.status !== 'ACTIVE') {
|
||||
throw new AppError(400, 'الموظف غير نشط - Employee must be ACTIVE');
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { employeeId: data.employeeId },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
|
||||
}
|
||||
|
||||
const emailExists = await prisma.user.findUnique({
|
||||
where: { email: data.email },
|
||||
});
|
||||
if (emailExists) {
|
||||
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
|
||||
}
|
||||
|
||||
const usernameExists = await prisma.user.findUnique({
|
||||
where: { username: data.username },
|
||||
});
|
||||
if (usernameExists) {
|
||||
throw new AppError(400, 'اسم المستخدم مستخدم بالفعل - Username already in use');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(data.password, config.security?.bcryptRounds || 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: hashedPassword,
|
||||
employeeId: data.employeeId,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
employeeId: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
employee: {
|
||||
include: {
|
||||
position: { select: { title: true, titleAr: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
entityId: user.id,
|
||||
action: 'CREATE',
|
||||
userId: createdById,
|
||||
changes: { created: { email: user.email, username: user.username } },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: UpdateUserData, updatedById: string) {
|
||||
const existing = await prisma.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
if (data.email && data.email !== existing.email) {
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
if (emailExists) {
|
||||
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.username && data.username !== existing.username) {
|
||||
const usernameExists = await prisma.user.findUnique({ where: { username: data.username } });
|
||||
if (usernameExists) {
|
||||
throw new AppError(400, 'اسم المستخدم مستخدم بالفعل - Username already in use');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Prisma.UserUpdateInput = {
|
||||
...(data.email && { email: data.email }),
|
||||
...(data.username && { username: data.username }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive }),
|
||||
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
|
||||
};
|
||||
|
||||
if (data.password && data.password.length >= 8) {
|
||||
updateData.password = await bcrypt.hash(data.password, config.security?.bcryptRounds || 10);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: { select: { title: true, titleAr: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = user;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: updatedById,
|
||||
changes: { before: existing, after: sanitized },
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async toggleUserActive(id: string, userId: string) {
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: !user.isActive },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: { select: { title: true, titleAr: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = updated;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
entityId: id,
|
||||
action: user.isActive ? 'DEACTIVATE' : 'ACTIVATE',
|
||||
userId,
|
||||
changes: { isActive: updated.isActive },
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async deleteUser(id: string, deletedById: string) {
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId: deletedById,
|
||||
changes: { softDeleted: true, email: user.email },
|
||||
});
|
||||
|
||||
return { success: true, message: 'User deactivated successfully' };
|
||||
}
|
||||
|
||||
// ========== AUDIT LOGS ==========
|
||||
|
||||
async getAuditLogs(filters: AuditLogFilters) {
|
||||
const page = filters.page || 1;
|
||||
const pageSize = Math.min(filters.pageSize || 20, 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
|
||||
if (filters.entityType) where.entityType = filters.entityType;
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.userId) where.userId = filters.userId;
|
||||
|
||||
if (filters.startDate || filters.endDate) {
|
||||
where.createdAt = {};
|
||||
if (filters.startDate) where.createdAt.gte = new Date(filters.startDate);
|
||||
if (filters.endDate) where.createdAt.lte = new Date(filters.endDate);
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
// ========== STATS ==========
|
||||
|
||||
async getStats() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalUsers, activeUsers, inactiveUsers, loginsToday] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { isActive: true } }),
|
||||
prisma.user.count({ where: { isActive: false } }),
|
||||
prisma.user.count({
|
||||
where: { lastLogin: { gte: today } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
inactiveUsers,
|
||||
loginsToday,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== POSITIONS (ROLES) ==========
|
||||
|
||||
async getPositions() {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: {
|
||||
employees: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { level: 'asc' },
|
||||
});
|
||||
|
||||
const withUserCount = await Promise.all(
|
||||
positions.map(async (p) => {
|
||||
const usersCount = await prisma.user.count({
|
||||
where: {
|
||||
employee: { positionId: p.id },
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
return { ...p, usersCount };
|
||||
})
|
||||
);
|
||||
|
||||
return withUserCount;
|
||||
}
|
||||
|
||||
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
|
||||
const position = await prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
}
|
||||
|
||||
await prisma.positionPermission.deleteMany({
|
||||
where: { positionId },
|
||||
});
|
||||
|
||||
if (permissions.length > 0) {
|
||||
await prisma.positionPermission.createMany({
|
||||
data: permissions.map((p) => ({
|
||||
positionId,
|
||||
module: p.module,
|
||||
resource: p.resource,
|
||||
actions: p.actions,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
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';
|
||||
@@ -10,6 +11,7 @@ import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||
const router = Router();
|
||||
|
||||
// Module routes
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
|
||||
@@ -1,65 +1,79 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { FileText, Filter, Download, User, Clock, Activity } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, Filter, Download, User, Clock, Activity } from 'lucide-react';
|
||||
import { auditLogsAPI } from '@/lib/api/admin';
|
||||
import type { AuditLog } from '@/lib/api/admin';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function AuditLogs() {
|
||||
const logs = [
|
||||
{
|
||||
id: '1',
|
||||
user: 'أحمد محمد',
|
||||
action: 'قام بإنشاء مستخدم جديد',
|
||||
module: 'إدارة المستخدمين',
|
||||
details: 'إنشاء مستخدم: mohammed.ali@example.com',
|
||||
ip: '192.168.1.100',
|
||||
timestamp: '2024-01-06 14:30:15',
|
||||
level: 'info'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
user: 'فاطمة الزهراني',
|
||||
action: 'قامت بتعديل صلاحيات دور',
|
||||
module: 'الأدوار والصلاحيات',
|
||||
details: 'تعديل صلاحيات دور "مدير المبيعات"',
|
||||
ip: '192.168.1.101',
|
||||
timestamp: '2024-01-06 13:45:30',
|
||||
level: 'warning'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
user: 'النظام',
|
||||
action: 'تم إنشاء نسخة احتياطية تلقائية',
|
||||
module: 'النسخ الاحتياطي',
|
||||
details: 'نسخة احتياطية تلقائية - 45.2 MB',
|
||||
ip: 'system',
|
||||
timestamp: '2024-01-06 02:00:00',
|
||||
level: 'success'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
user: 'محمد خالد',
|
||||
action: 'محاولة تسجيل دخول فاشلة',
|
||||
module: 'المصادقة',
|
||||
details: 'محاولة تسجيل دخول فاشلة لـ: admin@example.com',
|
||||
ip: '192.168.1.150',
|
||||
timestamp: '2024-01-06 11:20:45',
|
||||
level: 'error'
|
||||
}
|
||||
]
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [entityType, setEntityType] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'warning':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await auditLogsAPI.getAll({
|
||||
entityType: entityType || undefined,
|
||||
action: action || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setLogs(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل السجل');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, action, startDate, endDate, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
const getActionLabel = (a: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
CREATE: 'إنشاء',
|
||||
UPDATE: 'تحديث',
|
||||
DELETE: 'حذف',
|
||||
ACTIVATE: 'تفعيل',
|
||||
DEACTIVATE: 'تعطيل',
|
||||
};
|
||||
return labels[a] || a;
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const headers = ['المستخدم', 'نوع الكيان', 'الإجراء', 'التاريخ'];
|
||||
const rows = logs.map((l) => [
|
||||
l.user?.username || l.userId,
|
||||
l.entityType,
|
||||
getActionLabel(l.action),
|
||||
formatDate(l.createdAt),
|
||||
]);
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -68,67 +82,92 @@ export default function AuditLogs() {
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">سجل العمليات</h1>
|
||||
<p className="text-gray-600">عرض وتتبع جميع العمليات التي تمت على النظام</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all shadow-md hover:shadow-lg">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
<span className="font-semibold">تصدير السجل</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
{[
|
||||
{ label: 'إجمالي العمليات', value: '1,234', color: 'bg-blue-500' },
|
||||
{ label: 'اليوم', value: '45', color: 'bg-green-500' },
|
||||
{ label: 'الأسبوع', value: '312', color: 'bg-purple-500' },
|
||||
{ label: 'أخطاء', value: '3', color: 'bg-red-500' }
|
||||
].map((stat, index) => (
|
||||
<div key={index} className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<div className={`${stat.color} w-12 h-12 rounded-lg flex items-center justify-center mb-3`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<div className="bg-blue-500 w-12 h-12 rounded-lg flex items-center justify-center mb-3">
|
||||
<Activity className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-1">{stat.value}</h3>
|
||||
<p className="text-sm text-gray-600">{stat.label}</p>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-1">{total}</h3>
|
||||
<p className="text-sm text-gray-600">إجمالي العمليات في الصفحة الحالية</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<div className="bg-green-500 w-12 h-12 rounded-lg flex items-center justify-center mb-3">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-1">{logs.length}</h3>
|
||||
<p className="text-sm text-gray-600">عرض في هذه الصفحة</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-md p-6 mb-8 border border-gray-100">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث..."
|
||||
placeholder="نوع الكيان..."
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">جميع الوحدات</option>
|
||||
<option value="users">إدارة المستخدمين</option>
|
||||
<option value="roles">الأدوار</option>
|
||||
<option value="backup">النسخ الاحتياطي</option>
|
||||
</select>
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">جميع المستويات</option>
|
||||
<option value="success">نجاح</option>
|
||||
<option value="info">معلومات</option>
|
||||
<option value="warning">تحذير</option>
|
||||
<option value="error">خطأ</option>
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">جميع الإجراءات</option>
|
||||
<option value="CREATE">إنشاء</option>
|
||||
<option value="UPDATE">تحديث</option>
|
||||
<option value="DELETE">حذف</option>
|
||||
<option value="ACTIVATE">تفعيل</option>
|
||||
<option value="DEACTIVATE">تعطيل</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPage(1)}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
بحث
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-md border border-gray-100 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center text-red-600">{error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">المستخدم</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">نوع الكيان</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">الإجراء</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">الوحدة</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">التفاصيل</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">التاريخ</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold text-gray-900">المستوى</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
@@ -137,29 +176,28 @@ export default function AuditLogs() {
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-900 text-sm">{log.user}</span>
|
||||
<span className="font-medium text-gray-900 text-sm">
|
||||
{log.user?.username || log.userId}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-700">{log.action}</span>
|
||||
<span className="text-sm font-medium text-blue-600">{log.entityType}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm font-medium text-blue-600">{log.module}</span>
|
||||
<span className="text-sm text-gray-700">{getActionLabel(log.action)}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-600">{log.details}</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{log.entityType} #{log.entityId.slice(0, 8)}...
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-700">{log.timestamp}</span>
|
||||
<span className="text-sm text-gray-700">{formatDate(log.createdAt)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex px-3 py-1 rounded-full text-xs font-medium ${getLevelColor(log.level)}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -168,22 +206,35 @@ export default function AuditLogs() {
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
عرض 1-4 من 1,234 عملية
|
||||
عرض{' '}
|
||||
<span className="font-semibold">
|
||||
{logs.length ? (page - 1) * pageSize + 1 : 0}-{Math.min(page * pageSize, total)}
|
||||
</span>{' '}
|
||||
من <span className="font-semibold">{total}</span> عملية
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
السابق
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium">
|
||||
1
|
||||
</button>
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<span className="px-4 py-2 text-sm text-gray-600">
|
||||
صفحة {page} من {Math.max(1, Math.ceil(total / pageSize))}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(Math.ceil(total / pageSize) || 1, p + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
التالي
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
Users,
|
||||
Shield,
|
||||
@@ -8,83 +9,105 @@ import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
TrendingUp,
|
||||
Server
|
||||
} from 'lucide-react'
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { statsAPI, auditLogsAPI } from '@/lib/api/admin';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { user } = useAuth()
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState<{ totalUsers: number; activeUsers: number; inactiveUsers: number; loginsToday: number } | null>(null);
|
||||
const [recentLogs, setRecentLogs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const stats = [
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, logsRes] = await Promise.all([
|
||||
statsAPI.get(),
|
||||
auditLogsAPI.getAll({ pageSize: 10, page: 1 }),
|
||||
]);
|
||||
setStats(s);
|
||||
setRecentLogs(logsRes.data || []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const getActionLabel = (a: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
CREATE: 'قام بإنشاء',
|
||||
UPDATE: 'قام بتحديث',
|
||||
DELETE: 'قام بحذف',
|
||||
ACTIVATE: 'قام بتفعيل',
|
||||
DEACTIVATE: 'قام بتعطيل',
|
||||
};
|
||||
return labels[a] || a;
|
||||
};
|
||||
|
||||
const formatTime = (d: string) => {
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (mins < 60) return `منذ ${mins} دقيقة`;
|
||||
if (hours < 24) return `منذ ${hours} ساعة`;
|
||||
return new Date(d).toLocaleString('ar-SA', { dateStyle: 'short', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<LoadingSpinner size="lg" message="جاري تحميل لوحة التحكم..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
icon: Users,
|
||||
label: 'إجمالي المستخدمين',
|
||||
value: '24',
|
||||
change: '+3 هذا الشهر',
|
||||
color: 'bg-blue-500'
|
||||
value: stats?.totalUsers ?? '0',
|
||||
change: stats ? `+${stats.activeUsers} نشط` : '-',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
label: 'الأدوار النشطة',
|
||||
value: '8',
|
||||
change: '2 مخصص',
|
||||
color: 'bg-purple-500'
|
||||
value: '-',
|
||||
change: 'من الأدوار',
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
label: 'آخر نسخة احتياطية',
|
||||
value: 'منذ ساعتين',
|
||||
value: 'قريباً',
|
||||
change: 'تلقائي يومياً',
|
||||
color: 'bg-green-500'
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
label: 'صحة النظام',
|
||||
value: '99.9%',
|
||||
change: 'ممتاز',
|
||||
color: 'bg-teal-500'
|
||||
}
|
||||
]
|
||||
|
||||
const systemAlerts = [
|
||||
{
|
||||
type: 'warning',
|
||||
message: 'يوجد 3 مستخدمين لم يسجلوا الدخول منذ 30 يوم',
|
||||
time: 'منذ ساعة'
|
||||
label: 'تسجيل الدخول اليوم',
|
||||
value: String(stats?.loginsToday ?? 0),
|
||||
change: 'مستخدم',
|
||||
color: 'bg-teal-500',
|
||||
},
|
||||
];
|
||||
|
||||
const systemAlerts: { type: 'info' | 'warning'; message: string; time: string }[] = [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'تحديث النظام متاح - الإصدار 1.1.0',
|
||||
time: 'منذ 3 ساعات'
|
||||
}
|
||||
]
|
||||
|
||||
const recentActivities = [
|
||||
{
|
||||
user: 'أحمد محمد',
|
||||
action: 'قام بإنشاء مستخدم جديد',
|
||||
time: 'منذ 10 دقائق'
|
||||
message: 'النسخ الاحتياطي والتصدير ستكون متاحة قريباً',
|
||||
time: '-',
|
||||
},
|
||||
{
|
||||
user: 'فاطمة علي',
|
||||
action: 'قام بتحديث صلاحيات الدور "مدير المبيعات"',
|
||||
time: 'منذ 25 دقيقة'
|
||||
},
|
||||
{
|
||||
user: 'النظام',
|
||||
action: 'تم إنشاء نسخة احتياطية تلقائية',
|
||||
time: 'منذ ساعتين'
|
||||
},
|
||||
{
|
||||
user: 'محمد خالد',
|
||||
action: 'قام بتغيير إعدادات البريد الإلكتروني',
|
||||
time: 'منذ 3 ساعات'
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">لوحة تحكم المدير</h1>
|
||||
<p className="text-gray-600">مرحباً {user?.username}، إليك نظرة عامة على النظام</p>
|
||||
@@ -92,8 +115,8 @@ export default function AdminDashboard() {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon
|
||||
{statCards.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={index} className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -105,7 +128,7 @@ export default function AdminDashboard() {
|
||||
<p className="text-sm text-gray-600 mb-1">{stat.label}</p>
|
||||
<p className="text-xs text-gray-500">{stat.change}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -149,17 +172,25 @@ export default function AdminDashboard() {
|
||||
النشاطات الأخيرة
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.map((activity, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mt-2"></div>
|
||||
{recentLogs.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">لا توجد نشاطات حديثة</p>
|
||||
) : (
|
||||
recentLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mt-2" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-900">
|
||||
<span className="font-semibold">{activity.user}</span> {activity.action}
|
||||
<span className="font-semibold">{log.user?.username || 'نظام'}</span>{' '}
|
||||
{getActionLabel(log.action)} {log.entityType}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{activity.time}</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{formatTime(log.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +205,7 @@ export default function AdminDashboard() {
|
||||
{[
|
||||
{ name: 'قاعدة البيانات', status: 'operational', uptime: '99.99%' },
|
||||
{ name: 'خادم التطبيق', status: 'operational', uptime: '99.95%' },
|
||||
{ name: 'خدمة البريد', status: 'operational', uptime: '99.90%' }
|
||||
{ name: 'خدمة البريد', status: 'قريباً', uptime: '-' },
|
||||
].map((service, index) => (
|
||||
<div key={index} className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -182,9 +213,6 @@ export default function AdminDashboard() {
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Uptime: {service.uptime}</p>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500" style={{ width: service.uptime }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -211,15 +239,14 @@ export default function AdminDashboard() {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/backup"
|
||||
href="/admin/audit-logs"
|
||||
className="bg-gradient-to-br from-green-500 to-green-600 text-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-all transform hover:-translate-y-1"
|
||||
>
|
||||
<Database className="h-8 w-8 mb-3" />
|
||||
<h3 className="text-lg font-bold mb-2">النسخ الاحتياطي</h3>
|
||||
<p className="text-sm text-green-100">نسخ واستعادة قاعدة البيانات</p>
|
||||
<Activity className="h-8 w-8 mb-3" />
|
||||
<h3 className="text-lg font-bold mb-2">سجل العمليات</h3>
|
||||
<p className="text-sm text-green-100">عرض وتتبع العمليات</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +1,147 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Users,
|
||||
Check,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Shield, Edit, Users, Check, X } from 'lucide-react';
|
||||
import { positionsAPI } from '@/lib/api/admin';
|
||||
import type { PositionRole, PositionPermission } from '@/lib/api/admin';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
];
|
||||
|
||||
const ACTIONS = [
|
||||
{ id: 'read', name: 'عرض' },
|
||||
{ id: 'create', name: 'إنشاء' },
|
||||
{ id: 'update', name: 'تعديل' },
|
||||
{ id: 'delete', name: 'حذف' },
|
||||
{ id: 'export', name: 'تصدير' },
|
||||
{ id: 'approve', name: 'اعتماد' },
|
||||
{ id: 'merge', name: 'دمج' },
|
||||
];
|
||||
|
||||
function hasAction(permission: PositionPermission | undefined, action: string): boolean {
|
||||
if (!permission?.actions) return false;
|
||||
const actions = Array.isArray(permission.actions) ? permission.actions : [];
|
||||
return actions.includes('*') || actions.includes('all') || actions.includes(action);
|
||||
}
|
||||
|
||||
function buildPermissionsFromMatrix(matrix: Record<string, Record<string, boolean>>) {
|
||||
return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => {
|
||||
const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id);
|
||||
return {
|
||||
module: m.id,
|
||||
resource: '*',
|
||||
actions: actions.length === ACTIONS.length ? ['*'] : actions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<string, Record<string, boolean>> {
|
||||
const matrix: Record<string, Record<string, boolean>> = {};
|
||||
for (const m of MODULES) {
|
||||
matrix[m.id] = {};
|
||||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||||
const hasAll = perm && (Array.isArray(perm.actions)
|
||||
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
|
||||
: false);
|
||||
for (const a of ACTIONS) {
|
||||
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
export default function RolesManagement() {
|
||||
const [selectedRole, setSelectedRole] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [roles, setRoles] = useState<PositionRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Modules and their permissions
|
||||
const modules = [
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'إدارة جهات الاتصال',
|
||||
nameEn: 'Contact Management'
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
name: 'إدارة علاقات العملاء',
|
||||
nameEn: 'CRM'
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'المخزون والأصول',
|
||||
nameEn: 'Inventory & Assets'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
name: 'المهام والمشاريع',
|
||||
nameEn: 'Tasks & Projects'
|
||||
},
|
||||
{
|
||||
id: 'hr',
|
||||
name: 'الموارد البشرية',
|
||||
nameEn: 'HR Management'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'التسويق',
|
||||
nameEn: 'Marketing'
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const pos = await positionsAPI.getAll();
|
||||
setRoles(pos);
|
||||
if (selectedRoleId && !pos.find((p) => p.id === selectedRoleId)) {
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
]
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل الأدوار');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedRoleId]);
|
||||
|
||||
const permissions = [
|
||||
{ id: 'canView', name: 'عرض', icon: '👁️' },
|
||||
{ id: 'canCreate', name: 'إنشاء', icon: '➕' },
|
||||
{ id: 'canEdit', name: 'تعديل', icon: '✏️' },
|
||||
{ id: 'canDelete', name: 'حذف', icon: '🗑️' },
|
||||
{ id: 'canExport', name: 'تصدير', icon: '📤' },
|
||||
{ id: 'canApprove', name: 'اعتماد', icon: '✅' }
|
||||
]
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
// Mock roles data
|
||||
const roles = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'المدير العام',
|
||||
nameEn: 'General Manager',
|
||||
description: 'صلاحيات كاملة على النظام',
|
||||
usersCount: 2,
|
||||
permissions: {
|
||||
contacts: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true },
|
||||
crm: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true },
|
||||
inventory: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true },
|
||||
projects: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true },
|
||||
hr: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true },
|
||||
marketing: { canView: true, canCreate: true, canEdit: true, canDelete: true, canExport: true, canApprove: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'مدير المبيعات',
|
||||
nameEn: 'Sales Manager',
|
||||
description: 'إدارة المبيعات والعملاء مع صلاحيات الاعتماد',
|
||||
usersCount: 5,
|
||||
permissions: {
|
||||
contacts: { canView: true, canCreate: true, canEdit: true, canDelete: false, canExport: true, canApprove: false },
|
||||
crm: { canView: true, canCreate: true, canEdit: true, canDelete: false, canExport: true, canApprove: true },
|
||||
inventory: { canView: true, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false },
|
||||
projects: { canView: true, canCreate: true, canEdit: true, canDelete: false, canExport: false, canApprove: false },
|
||||
hr: { canView: false, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false },
|
||||
marketing: { canView: true, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'مندوب مبيعات',
|
||||
nameEn: 'Sales Representative',
|
||||
description: 'إدخال وتعديل بيانات المبيعات الأساسية',
|
||||
usersCount: 12,
|
||||
permissions: {
|
||||
contacts: { canView: true, canCreate: true, canEdit: true, canDelete: false, canExport: false, canApprove: false },
|
||||
crm: { canView: true, canCreate: true, canEdit: true, canDelete: false, canExport: false, canApprove: false },
|
||||
inventory: { canView: true, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false },
|
||||
projects: { canView: true, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false },
|
||||
hr: { canView: false, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false },
|
||||
marketing: { canView: false, canCreate: false, canEdit: false, canDelete: false, canExport: false, canApprove: false }
|
||||
}
|
||||
}
|
||||
]
|
||||
const currentRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
const currentRole = roles.find(r => r.id === selectedRole)
|
||||
useEffect(() => {
|
||||
if (currentRole) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
|
||||
}
|
||||
}, [currentRole?.id, currentRole?.permissions]);
|
||||
|
||||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||||
setPermissionMatrix((prev) => ({
|
||||
...prev,
|
||||
[moduleId]: {
|
||||
...(prev[moduleId] || {}),
|
||||
[actionId]: !prev[moduleId]?.[actionId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedRoleId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||||
await positionsAPI.updatePermissions(selectedRoleId, permissions);
|
||||
setShowEditModal(false);
|
||||
fetchRoles();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRole = (id: string) => {
|
||||
setSelectedRoleId(id);
|
||||
setShowEditModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1>
|
||||
<p className="text-gray-600">إدارة أدوار المستخدمين ومصفوفة الصلاحيات</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="font-semibold">إضافة دور جديد</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-600 p-12">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Roles List */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
@@ -134,41 +150,43 @@ export default function RolesManagement() {
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => setSelectedRole(role.id)}
|
||||
onClick={() => handleSelectRole(role.id)}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedRole === role.id
|
||||
selectedRoleId === role.id
|
||||
? 'border-purple-600 bg-purple-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-purple-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${selectedRole === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}>
|
||||
<Shield className={`h-5 w-5 ${selectedRole === role.id ? 'text-white' : 'text-purple-600'}`} />
|
||||
<div
|
||||
className={`p-2 rounded-lg ${selectedRoleId === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}
|
||||
>
|
||||
<Shield
|
||||
className={`h-5 w-5 ${selectedRoleId === role.id ? 'text-white' : 'text-purple-600'}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{role.name}</h3>
|
||||
<p className="text-xs text-gray-600">{role.nameEn}</p>
|
||||
<h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3>
|
||||
<p className="text-xs text-gray-600">{role.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3">{role.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{role.usersCount} مستخدم</span>
|
||||
<span>{role.usersCount ?? role._count?.employees ?? 0} مستخدم</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedRoleId(role.id);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,18 +199,19 @@ export default function RolesManagement() {
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{currentRole.name}</h2>
|
||||
<p className="text-gray-600">{currentRole.description}</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{currentRole.titleAr || currentRole.title}</h2>
|
||||
<p className="text-gray-600">{currentRole.title}</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
حفظ التغييرات
|
||||
<button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
تعديل الصلاحيات
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
@@ -200,20 +219,15 @@ export default function RolesManagement() {
|
||||
<th className="px-4 py-3 text-right text-sm font-bold text-gray-900 min-w-[200px]">
|
||||
الوحدة
|
||||
</th>
|
||||
{permissions.map((perm) => (
|
||||
{ACTIONS.map((perm) => (
|
||||
<th key={perm.id} className="px-4 py-3 text-center text-sm font-bold text-gray-900">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xl">{perm.icon}</span>
|
||||
<span>{perm.name}</span>
|
||||
</div>
|
||||
{perm.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{modules.map((module) => {
|
||||
const modulePerms = currentRole.permissions[module.id as keyof typeof currentRole.permissions]
|
||||
return (
|
||||
{MODULES.map((module) => (
|
||||
<tr key={module.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-4">
|
||||
<div>
|
||||
@@ -221,60 +235,27 @@ export default function RolesManagement() {
|
||||
<p className="text-xs text-gray-600">{module.nameEn}</p>
|
||||
</div>
|
||||
</td>
|
||||
{permissions.map((perm) => {
|
||||
const hasPermission = modulePerms?.[perm.id as keyof typeof modulePerms]
|
||||
{ACTIONS.map((action) => {
|
||||
const hasPermission = permissionMatrix[module.id]?.[action.id];
|
||||
return (
|
||||
<td key={perm.id} className="px-4 py-4 text-center">
|
||||
<label className="inline-flex items-center justify-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!hasPermission}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all ${
|
||||
<td key={action.id} className="px-4 py-4 text-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${
|
||||
hasPermission
|
||||
? 'bg-green-500 shadow-md'
|
||||
: 'bg-gray-200 hover:bg-gray-300'
|
||||
}`}>
|
||||
{hasPermission ? (
|
||||
<Check className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<X className="h-6 w-6 text-gray-500" />
|
||||
)}
|
||||
? 'bg-green-500 text-white shadow-md'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</div>
|
||||
</label>
|
||||
</td>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-bold text-blue-900 mb-2">💡 معلومات:</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• انقر على المربعات لتفعيل أو إلغاء الصلاحيات</li>
|
||||
<li>• الصلاحيات تطبق فوراً على جميع مستخدمي هذا الدور</li>
|
||||
<li>• يجب أن يكون لديك صلاحية "عرض" على الأقل للوصول إلى الوحدة</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button className="flex-1 px-4 py-3 border-2 border-green-600 text-green-600 rounded-lg hover:bg-green-50 transition-colors font-semibold">
|
||||
✅ منح جميع الصلاحيات
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-3 border-2 border-red-600 text-red-600 rounded-lg hover:bg-red-50 transition-colors font-semibold">
|
||||
❌ إلغاء جميع الصلاحيات
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-3 border-2 border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-semibold">
|
||||
👁️ صلاحيات العرض فقط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -286,7 +267,74 @@ export default function RolesManagement() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{/* Edit Permissions Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
|
||||
size="2xl"
|
||||
>
|
||||
{currentRole && (
|
||||
<div>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold text-gray-900">الوحدة</th>
|
||||
{ACTIONS.map((perm) => (
|
||||
<th key={perm.id} className="px-4 py-3 text-center text-sm font-bold text-gray-900">
|
||||
{perm.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((module) => (
|
||||
<tr key={module.id}>
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold text-gray-900">{module.name}</p>
|
||||
</td>
|
||||
{ACTIONS.map((action) => {
|
||||
const hasPermission = permissionMatrix[module.id]?.[action.id];
|
||||
return (
|
||||
<td key={action.id} className="px-4 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePermission(module.id, action.id)}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${
|
||||
hasPermission ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePermissions}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
@@ -10,53 +10,132 @@ import {
|
||||
Lock,
|
||||
Unlock,
|
||||
Mail,
|
||||
Phone,
|
||||
Shield,
|
||||
Calendar,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
} from 'lucide-react';
|
||||
import { usersAPI, statsAPI, positionsAPI } from '@/lib/api/admin';
|
||||
import { employeesAPI } from '@/lib/api/employees';
|
||||
import type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin';
|
||||
import type { Employee } from '@/lib/api/employees';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function UsersManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<string>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||
const [stats, setStats] = useState({ totalUsers: 0, activeUsers: 0, inactiveUsers: 0, loginsToday: 0 });
|
||||
const [positions, setPositions] = useState<{ id: string; title: string; titleAr?: string | null }[]>([]);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// Mock data - replace with actual API calls
|
||||
const users = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'gm@atmata.com',
|
||||
fullName: 'أحمد محمد السعيد',
|
||||
role: 'المدير العام',
|
||||
status: 'active',
|
||||
lastLogin: '2024-01-06 14:30',
|
||||
createdAt: '2024-01-01'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
username: 'salesmanager',
|
||||
email: 'sales.manager@atmata.com',
|
||||
fullName: 'فاطمة الزهراني',
|
||||
role: 'مدير المبيعات',
|
||||
status: 'active',
|
||||
lastLogin: '2024-01-06 09:15',
|
||||
createdAt: '2024-01-01'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
username: 'salesrep',
|
||||
email: 'sales.rep@atmata.com',
|
||||
fullName: 'محمد القحطاني',
|
||||
role: 'مندوب مبيعات',
|
||||
status: 'active',
|
||||
lastLogin: '2024-01-05 16:45',
|
||||
createdAt: '2024-01-01'
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await usersAPI.getAll({
|
||||
search: searchTerm || undefined,
|
||||
status: selectedStatus === 'active' ? 'active' : selectedStatus === 'inactive' ? 'inactive' : undefined,
|
||||
positionId: selectedRole || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setUsers(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل المستخدمين');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
]
|
||||
}, [searchTerm, selectedRole, selectedStatus, page, pageSize]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const s = await statsAPI.get();
|
||||
setStats(s);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchPositions = useCallback(async () => {
|
||||
try {
|
||||
const pos = await positionsAPI.getAll();
|
||||
setPositions(pos.map((p) => ({ id: p.id, title: p.title, titleAr: p.titleAr })));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
try {
|
||||
const res = await employeesAPI.getAll({ status: 'ACTIVE', pageSize: 500 });
|
||||
setEmployees(res.employees);
|
||||
} catch {
|
||||
setEmployees([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchPositions();
|
||||
}, [fetchStats, fetchPositions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAddModal || showEditModal) fetchEmployees();
|
||||
}, [showAddModal, showEditModal, fetchEmployees]);
|
||||
|
||||
const handleToggleActive = async (id: string) => {
|
||||
try {
|
||||
await usersAPI.toggleActive(id);
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل تحديث الحالة');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (deleteConfirm !== id) return;
|
||||
try {
|
||||
await usersAPI.delete(id);
|
||||
setDeleteConfirm(null);
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل حذف المستخدم');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleString('ar-SA', { dateStyle: 'short', timeStyle: 'short' }) : '-';
|
||||
|
||||
const getFullName = (u: User) =>
|
||||
u.employee
|
||||
? `${u.employee.firstName} ${u.employee.lastName}`.trim() ||
|
||||
`${u.employee.firstNameAr || ''} ${u.employee.lastNameAr || ''}`.trim() ||
|
||||
u.username
|
||||
: u.username;
|
||||
|
||||
const getRole = (u: User) =>
|
||||
u.employee?.position?.titleAr || u.employee?.position?.title || '-';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">إدارة المستخدمين</h1>
|
||||
@@ -74,10 +153,10 @@ export default function UsersManagement() {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
{[
|
||||
{ label: 'إجمالي المستخدمين', value: '24', color: 'bg-blue-500' },
|
||||
{ label: 'المستخدمون النشطون', value: '21', color: 'bg-green-500' },
|
||||
{ label: 'المستخدمون المعطلون', value: '3', color: 'bg-red-500' },
|
||||
{ label: 'تسجيل دخول اليوم', value: '18', color: 'bg-purple-500' }
|
||||
{ label: 'إجمالي المستخدمين', value: String(stats.totalUsers), color: 'bg-blue-500' },
|
||||
{ label: 'المستخدمون النشطون', value: String(stats.activeUsers), color: 'bg-green-500' },
|
||||
{ label: 'المستخدمون المعطلون', value: String(stats.inactiveUsers), color: 'bg-red-500' },
|
||||
{ label: 'تسجيل دخول اليوم', value: String(stats.loginsToday), color: 'bg-purple-500' },
|
||||
].map((stat, index) => (
|
||||
<div key={index} className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<div className={`${stat.color} w-12 h-12 rounded-lg flex items-center justify-center mb-3`}>
|
||||
@@ -99,27 +178,50 @@ export default function UsersManagement() {
|
||||
placeholder="بحث بالاسم أو البريد..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchUsers()}
|
||||
className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">جميع الأدوار</option>
|
||||
<option value="admin">المدير العام</option>
|
||||
<option value="manager">مدير المبيعات</option>
|
||||
<option value="sales">مندوب مبيعات</option>
|
||||
{positions.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.titleAr || p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">جميع الحالات</option>
|
||||
<option value="active">نشط</option>
|
||||
<option value="inactive">معطل</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchUsers()}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
بحث
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-xl shadow-md border border-gray-100 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center text-red-600">{error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
@@ -138,10 +240,10 @@ export default function UsersManagement() {
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-bold">
|
||||
{user.fullName.charAt(0)}
|
||||
{getFullName(user).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{user.fullName}</p>
|
||||
<p className="font-semibold text-gray-900">{getFullName(user)}</p>
|
||||
<p className="text-sm text-gray-600">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,11 +257,11 @@ export default function UsersManagement() {
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-900">{user.role}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{getRole(user)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.status === 'active' ? (
|
||||
{user.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
نشط
|
||||
@@ -174,24 +276,52 @@ export default function UsersManagement() {
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm">{user.lastLogin}</span>
|
||||
<span className="text-sm">{formatDate(user.lastLogin)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingUser(user);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="تعديل"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors">
|
||||
{user.status === 'active' ? (
|
||||
<Lock className="h-4 w-4" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleToggleActive(user.id)}
|
||||
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
title={user.isActive ? 'تعطيل' : 'تفعيل'}
|
||||
>
|
||||
{user.isActive ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
||||
</button>
|
||||
<button className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
{deleteConfirm === user.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
تأكيد
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(user.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -203,127 +333,355 @@ export default function UsersManagement() {
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
عرض <span className="font-semibold">1-3</span> من <span className="font-semibold">24</span> مستخدم
|
||||
عرض{' '}
|
||||
<span className="font-semibold">
|
||||
{users.length ? (page - 1) * pageSize + 1 : 0}-{Math.min(page * pageSize, total)}
|
||||
</span>{' '}
|
||||
من <span className="font-semibold">{total}</span> مستخدم
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
السابق
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
1
|
||||
{Array.from({ length: Math.min(5, Math.ceil(total / pageSize) || 1) }, (_, i) => i + 1).map(
|
||||
(n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setPage(n)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
page === n
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
2
|
||||
</button>
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(Math.ceil(total / pageSize) || 1, p + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
التالي
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add User Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">إضافة مستخدم جديد</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الاسم الأول</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="أحمد"
|
||||
<AddUserModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddModal(false);
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
}}
|
||||
employees={employees}
|
||||
saving={saving}
|
||||
setSaving={setSaving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الاسم الأخير</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="محمد"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<EditUserModal
|
||||
isOpen={showEditModal}
|
||||
user={editingUser}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingUser(null);
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
}}
|
||||
employees={employees}
|
||||
saving={saving}
|
||||
setSaving={setSaving}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
employees,
|
||||
saving,
|
||||
setSaving,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
employees: Employee[];
|
||||
saving: boolean;
|
||||
setSaving: (v: boolean) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreateUserData & { isActive: boolean }>({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
employeeId: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.email || !form.username || !form.password || !form.employeeId) {
|
||||
alert('يرجى ملء جميع الحقول المطلوبة');
|
||||
return;
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
alert('كلمة المرور يجب أن تكون 8 أحرف على الأقل');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await usersAPI.create(form);
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'فشل إنشاء المستخدم';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="إضافة مستخدم جديد" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">اسم المستخدم</label>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الموظف المرتبط *</label>
|
||||
<select
|
||||
value={form.employeeId}
|
||||
onChange={(e) => {
|
||||
const emp = employees.find((em) => em.id === e.target.value);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
employeeId: e.target.value,
|
||||
email: emp?.email || f.email,
|
||||
username: emp ? `${emp.firstName}.${emp.lastName}`.toLowerCase().replace(/\s/g, '.') : f.username,
|
||||
}));
|
||||
}}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">اختر الموظف</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.firstName} {emp.lastName} - {emp.uniqueEmployeeId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">اسم المستخدم *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="ahmed.mohamed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">البريد الإلكتروني</label>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">البريد الإلكتروني *</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="ahmed@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">كلمة المرور</label>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">كلمة المرور *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الدور</label>
|
||||
<select className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">اختر الدور</option>
|
||||
<option value="admin">المدير العام</option>
|
||||
<option value="manager">مدير المبيعات</option>
|
||||
<option value="sales">مندوب مبيعات</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الموظف المرتبط</label>
|
||||
<select className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">اختر الموظف</option>
|
||||
<option value="1">أحمد محمد السعيد - EMP-2024-0001</option>
|
||||
<option value="2">فاطمة الزهراني - EMP-2024-0002</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="active"
|
||||
checked={form.isActive}
|
||||
onChange={(e) => setForm((f) => ({ ...f, isActive: e.target.checked }))}
|
||||
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
defaultChecked
|
||||
/>
|
||||
<label htmlFor="active" className="text-sm font-medium text-gray-700">
|
||||
تفعيل الحساب فوراً
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
|
||||
>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
|
||||
إلغاء
|
||||
</button>
|
||||
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
||||
إنشاء المستخدم
|
||||
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold disabled:opacity-50">
|
||||
{saving ? 'جاري الحفظ...' : 'إنشاء المستخدم'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditUserModal({
|
||||
isOpen,
|
||||
user,
|
||||
onClose,
|
||||
onSuccess,
|
||||
employees,
|
||||
saving,
|
||||
setSaving,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
user: User | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
employees: Employee[];
|
||||
saving: boolean;
|
||||
setSaving: (v: boolean) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<UpdateUserData & { isActive?: boolean }>({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
employeeId: null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setForm({
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
password: '',
|
||||
employeeId: user.employeeId ?? undefined,
|
||||
isActive: user.isActive,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
if (!form.email || !form.username) {
|
||||
alert('يرجى ملء الحقول المطلوبة');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: UpdateUserData = {
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
isActive: form.isActive,
|
||||
employeeId: form.employeeId === '' ? null : form.employeeId || undefined,
|
||||
};
|
||||
if (form.password?.length && form.password.length >= 8) {
|
||||
data.password = form.password;
|
||||
}
|
||||
await usersAPI.update(user.id, data);
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'فشل تحديث المستخدم';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="تعديل المستخدم" size="lg">
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الموظف المرتبط</label>
|
||||
<select
|
||||
value={form.employeeId || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, employeeId: e.target.value || null }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">-- بدون موظف --</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.firstName} {emp.lastName} - {emp.uniqueEmployeeId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">اسم المستخدم *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">البريد الإلكتروني *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">كلمة مرور جديدة (اختياري)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value || undefined }))}
|
||||
minLength={8}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="اتركه فارغاً للإبقاء على كلمة المرور الحالية"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-active"
|
||||
checked={form.isActive ?? true}
|
||||
onChange={(e) => setForm((f) => ({ ...f, isActive: e.target.checked }))}
|
||||
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="edit-active" className="text-sm font-medium text-gray-700">
|
||||
الحساب نشط
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold disabled:opacity-50">
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,140 +1,266 @@
|
||||
import { api } from '../api'
|
||||
import { api } from '../api';
|
||||
|
||||
// Users API
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
status: string
|
||||
employeeId?: string
|
||||
employee?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
isActive: boolean;
|
||||
lastLogin?: string | null;
|
||||
employeeId?: string | null;
|
||||
employee?: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
firstNameAr?: string | null;
|
||||
lastNameAr?: string | null;
|
||||
position?: { id: string; title: string; titleAr?: string | null };
|
||||
department?: { name: string; nameAr?: string | null };
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
employeeId?: string
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
employeeId: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
email?: string
|
||||
username?: string
|
||||
password?: string
|
||||
status?: string
|
||||
employeeId?: string
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
employeeId?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UserFilters {
|
||||
search?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
positionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
getAll: async (): Promise<User[]> => {
|
||||
const response = await api.get('/auth/users')
|
||||
return response.data.data || response.data
|
||||
getAll: async (filters?: UserFilters): Promise<PaginatedResponse<User>> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.positionId) params.append('positionId', filters.positionId);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
||||
|
||||
const response = await api.get(`/admin/users?${params.toString()}`);
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data || [],
|
||||
pagination: response.data.pagination || { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<User> => {
|
||||
const response = await api.get(`/auth/users/${id}`)
|
||||
return response.data.data
|
||||
const response = await api.get(`/admin/users/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateUserData): Promise<User> => {
|
||||
const response = await api.post('/auth/register', data)
|
||||
return response.data.data
|
||||
const response = await api.post('/admin/users', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateUserData): Promise<User> => {
|
||||
const response = await api.put(`/auth/users/${id}`, data)
|
||||
return response.data.data
|
||||
const response = await api.put(`/admin/users/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
toggleActive: async (id: string): Promise<User> => {
|
||||
const response = await api.patch(`/admin/users/${id}/toggle-active`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/auth/users/${id}`)
|
||||
}
|
||||
await api.delete(`/admin/users/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Stats API
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
inactiveUsers: number;
|
||||
loginsToday: number;
|
||||
}
|
||||
|
||||
// Roles & Permissions API
|
||||
export const statsAPI = {
|
||||
get: async (): Promise<AdminStats> => {
|
||||
const response = await api.get('/admin/stats');
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Positions (Roles) API - maps to HR positions with permissions
|
||||
export interface PositionPermission {
|
||||
id: string;
|
||||
module: string;
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface PositionRole {
|
||||
id: string;
|
||||
title: string;
|
||||
titleAr?: string | null;
|
||||
code: string;
|
||||
department?: { name: string; nameAr?: string | null };
|
||||
permissions: PositionPermission[];
|
||||
usersCount: number;
|
||||
_count?: { employees: number };
|
||||
}
|
||||
|
||||
export const positionsAPI = {
|
||||
getAll: async (): Promise<PositionRole[]> => {
|
||||
const response = await api.get('/admin/positions');
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
updatePermissions: async (
|
||||
positionId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
): Promise<PositionRole> => {
|
||||
const response = await api.put(`/admin/positions/${positionId}/permissions`, {
|
||||
permissions,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Roles API - alias for positions (for compatibility with existing frontend)
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
nameAr?: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string
|
||||
module: string
|
||||
resource: string
|
||||
action: string
|
||||
id: string;
|
||||
name: string;
|
||||
nameAr?: string;
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
permissions: { id?: string; module: string; resource: string; actions: string[] }[];
|
||||
usersCount?: number;
|
||||
}
|
||||
|
||||
export const rolesAPI = {
|
||||
getAll: async (): Promise<Role[]> => {
|
||||
const response = await api.get('/admin/roles')
|
||||
return response.data.data || []
|
||||
const positions = await positionsAPI.getAll();
|
||||
return positions.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.title,
|
||||
nameAr: p.titleAr || undefined,
|
||||
title: p.title,
|
||||
titleAr: p.titleAr || undefined,
|
||||
permissions: p.permissions,
|
||||
usersCount: p.usersCount,
|
||||
}));
|
||||
},
|
||||
|
||||
update: async (id: string, permissions: Permission[]): Promise<Role> => {
|
||||
const response = await api.put(`/admin/roles/${id}/permissions`, { permissions })
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
update: async (
|
||||
id: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
): Promise<Role> => {
|
||||
const position = await positionsAPI.updatePermissions(id, permissions);
|
||||
return {
|
||||
id: position.id,
|
||||
name: position.title,
|
||||
nameAr: position.titleAr || undefined,
|
||||
permissions: position.permissions,
|
||||
usersCount: position.usersCount,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Audit Logs API
|
||||
export interface AuditLog {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
action: string
|
||||
userId: string
|
||||
user?: any
|
||||
changes?: any
|
||||
createdAt: string
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
userId: string;
|
||||
user?: { id: string; username: string; email: string };
|
||||
changes?: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const auditLogsAPI = {
|
||||
getAll: async (filters?: any): Promise<AuditLog[]> => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.entityType) params.append('entityType', filters.entityType)
|
||||
if (filters?.action) params.append('action', filters.action)
|
||||
if (filters?.startDate) params.append('startDate', filters.startDate)
|
||||
if (filters?.endDate) params.append('endDate', filters.endDate)
|
||||
getAll: async (filters?: AuditLogFilters): Promise<PaginatedResponse<AuditLog>> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.entityType) params.append('entityType', filters.entityType);
|
||||
if (filters?.action) params.append('action', filters.action);
|
||||
if (filters?.userId) params.append('userId', filters.userId);
|
||||
if (filters?.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters?.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
||||
|
||||
const response = await api.get(`/admin/audit-logs?${params.toString()}`)
|
||||
return response.data.data || []
|
||||
}
|
||||
}
|
||||
const response = await api.get(`/admin/audit-logs?${params.toString()}`);
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data || [],
|
||||
pagination: response.data.pagination || { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// System Settings API
|
||||
// System Settings API (placeholder - out of scope)
|
||||
export interface SystemSetting {
|
||||
key: string
|
||||
value: any
|
||||
description?: string
|
||||
key: string;
|
||||
value: unknown;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const settingsAPI = {
|
||||
getAll: async (): Promise<SystemSetting[]> => {
|
||||
const response = await api.get('/admin/settings')
|
||||
return response.data.data || []
|
||||
const response = await api.get('/admin/settings').catch(() => ({ data: { data: [] } }));
|
||||
return response.data?.data || [];
|
||||
},
|
||||
|
||||
update: async (key: string, value: any): Promise<SystemSetting> => {
|
||||
const response = await api.put(`/admin/settings/${key}`, { value })
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
update: async (key: string, value: unknown): Promise<SystemSetting> => {
|
||||
const response = await api.put(`/admin/settings/${key}`, { value });
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// System Health API
|
||||
// System Health API (placeholder - optional)
|
||||
export interface SystemHealth {
|
||||
status: string
|
||||
database: string
|
||||
memory: any
|
||||
uptime: number
|
||||
status: string;
|
||||
database: string;
|
||||
memory?: unknown;
|
||||
uptime?: number;
|
||||
}
|
||||
|
||||
export const healthAPI = {
|
||||
check: async (): Promise<SystemHealth> => {
|
||||
const response = await api.get('/admin/health')
|
||||
return response.data.data || response.data
|
||||
}
|
||||
}
|
||||
const response = await api.get('/admin/health').catch(() => ({ data: { data: {} } }));
|
||||
return response.data?.data || response.data || {};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user