From 842678674b2ed50f59e5aaedf8d8650020124007 Mon Sep 17 00:00:00 2001 From: Talal Sharabi Date: Thu, 19 Feb 2026 15:40:34 +0400 Subject: [PATCH] Admin panel: real data - backend API, users/audit/roles/stats, frontend wired Co-authored-by: Cursor --- backend/prisma/seed.ts | 10 + backend/scripts/add-admin-permission.ts | 41 + backend/src/modules/admin/admin.controller.ts | 150 +++ backend/src/modules/admin/admin.routes.ts | 103 +++ backend/src/modules/admin/admin.service.ts | 434 +++++++++ backend/src/routes/index.ts | 2 + frontend/src/app/admin/audit-logs/page.tsx | 349 ++++--- frontend/src/app/admin/page.tsx | 181 ++-- frontend/src/app/admin/roles/page.tsx | 542 ++++++----- frontend/src/app/admin/users/page.tsx | 856 +++++++++++++----- frontend/src/lib/api/admin.ts | 300 ++++-- 11 files changed, 2159 insertions(+), 809 deletions(-) create mode 100644 backend/scripts/add-admin-permission.ts create mode 100644 backend/src/modules/admin/admin.controller.ts create mode 100644 backend/src/modules/admin/admin.routes.ts create mode 100644 backend/src/modules/admin/admin.service.ts diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 7faeb5a..f624f78 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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: [ diff --git a/backend/scripts/add-admin-permission.ts b/backend/scripts/add-admin-permission.ts new file mode 100644 index 0000000..7878d98 --- /dev/null +++ b/backend/scripts/add-admin-permission.ts @@ -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()); diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..441e44e --- /dev/null +++ b/backend/src/modules/admin/admin.controller.ts @@ -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(); diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..3ebf286 --- /dev/null +++ b/backend/src/modules/admin/admin.routes.ts @@ -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; diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..d73e12b --- /dev/null +++ b/backend/src/modules/admin/admin.service.ts @@ -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(); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 479ddb6..fb35791 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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); diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/app/admin/audit-logs/page.tsx index e82db7e..10f4c98 100644 --- a/frontend/src/app/admin/audit-logs/page.tsx +++ b/frontend/src/app/admin/audit-logs/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = { + 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 (
@@ -68,122 +82,159 @@ export default function AuditLogs() {

سجل العمليات

عرض وتتبع جميع العمليات التي تمت على النظام

- -
- {[ - { 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) => ( -
-
- -
-

{stat.value}

-

{stat.label}

+
+
+
+
- ))} +

{total}

+

إجمالي العمليات في الصفحة الحالية

+
+
+
+ +
+

{logs.length}

+

عرض في هذه الصفحة

+
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" /> - - 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" + > + + + + + + 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" + /> + 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" />
+
-
- - - - - - - - - - - - - {logs.map((log) => ( - - - - - - - - - ))} - -
المستخدمالإجراءالوحدةالتفاصيلالتاريخالمستوى
-
- - {log.user} -
-
- {log.action} - - {log.module} - - {log.details} - -
- - {log.timestamp} -
-
- - {log.level} - -
-
- -
-

- عرض 1-4 من 1,234 عملية -

-
- - - + {loading ? ( +
+
-
+ ) : error ? ( +
{error}
+ ) : ( + <> +
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
المستخدمنوع الكيانالإجراءالتفاصيلالتاريخ
+
+ + + {log.user?.username || log.userId} + +
+
+ {log.entityType} + + {getActionLabel(log.action)} + + + {log.entityType} #{log.entityId.slice(0, 8)}... + + +
+ + {formatDate(log.createdAt)} +
+
+
+ +
+

+ عرض{' '} + + {logs.length ? (page - 1) * pageSize + 1 : 0}-{Math.min(page * pageSize, total)} + {' '} + من {total} عملية +

+
+ + + صفحة {page} من {Math.max(1, Math.ceil(total / pageSize))} + + +
+
+ + )}
- ) + ); } - diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index bc24b89..be52aad 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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([]); + 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 = { + 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 ( +
+ +
+ ); + } + + 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 (
- {/* Header */}

لوحة تحكم المدير

مرحباً {user?.username}، إليك نظرة عامة على النظام

@@ -92,8 +115,8 @@ export default function AdminDashboard() { {/* Stats Grid */}
- {stats.map((stat, index) => { - const Icon = stat.icon + {statCards.map((stat, index) => { + const Icon = stat.icon; return (
@@ -105,7 +128,7 @@ export default function AdminDashboard() {

{stat.label}

{stat.change}

- ) + ); })}
@@ -149,17 +172,25 @@ export default function AdminDashboard() { النشاطات الأخيرة
- {recentActivities.map((activity, index) => ( -
-
-
-

- {activity.user} {activity.action} -

-

{activity.time}

+ {recentLogs.length === 0 ? ( +

لا توجد نشاطات حديثة

+ ) : ( + recentLogs.map((log) => ( +
+
+
+

+ {log.user?.username || 'نظام'}{' '} + {getActionLabel(log.action)} {log.entityType} +

+

{formatTime(log.createdAt)}

+
-
- ))} + )) + )}
@@ -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) => (
@@ -182,9 +213,6 @@ export default function AdminDashboard() {

Uptime: {service.uptime}

-
-
-
))}
@@ -211,15 +239,14 @@ export default function AdminDashboard() { - -

النسخ الاحتياطي

-

نسخ واستعادة قاعدة البيانات

+ +

سجل العمليات

+

عرض وتتبع العمليات

- ) + ); } - diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index 2cad513..252c981 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -1,219 +1,233 @@ -'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>) { + 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> { + const matrix: Record> = {}; + 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(null) - const [showAddModal, setShowAddModal] = useState(false) + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [permissionMatrix, setPermissionMatrix] = useState>>({}); + 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); + + useEffect(() => { + if (currentRole) { + setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || [])); } - ] + }, [currentRole?.id, currentRole?.permissions]); - const currentRole = roles.find(r => r.id === selectedRole) + 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 (
- {/* Header */}

الأدوار والصلاحيات

إدارة أدوار المستخدمين ومصفوفة الصلاحيات

-
-
- {/* Roles List */} -
-

الأدوار ({roles.length})

- - {roles.map((role) => ( -
setSelectedRole(role.id)} - className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${ - selectedRole === role.id - ? 'border-purple-600 bg-purple-50 shadow-md' - : 'border-gray-200 bg-white hover:border-purple-300 hover:shadow-sm' - }`} - > -
-
-
- -
-
-

{role.name}

-

{role.nameEn}

+ {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( +
+ {/* Roles List */} +
+

الأدوار ({roles.length})

+ + {roles.map((role) => ( +
handleSelectRole(role.id)} + className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${ + selectedRoleId === role.id + ? 'border-purple-600 bg-purple-50 shadow-md' + : 'border-gray-200 bg-white hover:border-purple-300 hover:shadow-sm' + }`} + > +
+
+
+ +
+
+

{role.titleAr || role.title}

+

{role.title}

+
-
- -

{role.description}

- -
-
- - {role.usersCount} مستخدم -
- -
- -
-
- ))} -
+ ))} +
- {/* Permission Matrix */} -
- {currentRole ? ( -
-
-
-
-

{currentRole.name}

-

{currentRole.description}

+ {/* Permission Matrix */} +
+ {currentRole ? ( +
+
+
+
+

{currentRole.titleAr || currentRole.title}

+

{currentRole.title}

+
+
-
-
- -
-

مصفوفة الصلاحيات

- -
- - - - - {permissions.map((perm) => ( -
- الوحدة - -
- {perm.icon} - {perm.name} -
+
+

مصفوفة الصلاحيات

+
+ + + + - ))} - - - - {modules.map((module) => { - const modulePerms = currentRole.permissions[module.id as keyof typeof currentRole.permissions] - return ( + {ACTIONS.map((perm) => ( + + ))} + + + + {MODULES.map((module) => ( - {permissions.map((perm) => { - const hasPermission = modulePerms?.[perm.id as keyof typeof modulePerms] + {ACTIONS.map((action) => { + const hasPermission = permissionMatrix[module.id]?.[action.id]; return ( - - ) + ); })} - ) - })} - -
+ الوحدة
+ {perm.name} +
@@ -221,72 +235,106 @@ export default function RolesManagement() {

{module.nameEn}

-
-
- - {/* Legend */} -
-

💡 معلومات:

-
    -
  • • انقر على المربعات لتفعيل أو إلغاء الصلاحيات
  • -
  • • الصلاحيات تطبق فوراً على جميع مستخدمي هذا الدور
  • -
  • • يجب أن يكون لديك صلاحية "عرض" على الأقل للوصول إلى الوحدة
  • -
-
- - {/* Quick Actions */} -
- - - + ))} + +
+
-
- ) : ( -
- -

اختر دوراً لعرض الصلاحيات

-

اختر دور من القائمة لعرض وتعديل صلاحياته

-
- )} + ) : ( +
+ +

اختر دوراً لعرض الصلاحيات

+

اختر دور من القائمة لعرض وتعديل صلاحياته

+
+ )} +
-
-
- ) -} + )} + {/* Edit Permissions Modal */} + setShowEditModal(false)} + title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`} + size="2xl" + > + {currentRole && ( +
+
+ + + + + {ACTIONS.map((perm) => ( + + ))} + + + + {MODULES.map((module) => ( + + + {ACTIONS.map((action) => { + const hasPermission = permissionMatrix[module.id]?.[action.id]; + return ( + + ); + })} + + ))} + +
الوحدة + {perm.name} +
+

{module.name}

+
+ +
+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 51df98c..dfb30a5 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedRole, setSelectedRole] = useState(''); + const [selectedStatus, setSelectedStatus] = useState(''); + 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([]); + const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [saving, setSaving] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(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 (
- {/* Header */}

إدارة المستخدمين

@@ -74,10 +153,10 @@ export default function UsersManagement() { {/* Stats */}
{[ - { 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) => (
@@ -99,231 +178,510 @@ 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" />
- - 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" + > - - - + {positions.map((p) => ( + + ))} - - 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" + >
+
{/* Users Table */}
-
- - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - ))} - -
المستخدمالبريد الإلكترونيالدورالحالةآخر تسجيل دخولالإجراءات
-
-
- {user.fullName.charAt(0)} -
-
-

{user.fullName}

-

@{user.username}

-
-
-
-
- - {user.email} -
-
-
- - {user.role} -
-
- {user.status === 'active' ? ( - -
- نشط -
- ) : ( - -
- معطل -
- )} -
-
- - {user.lastLogin} -
-
-
- - - -
-
-
- - {/* Pagination */} -
-

- عرض 1-3 من 24 مستخدم -

-
- - - - + {loading ? ( +
+
-
+ ) : error ? ( +
{error}
+ ) : ( + <> +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
المستخدمالبريد الإلكترونيالدورالحالةآخر تسجيل دخولالإجراءات
+
+
+ {getFullName(user).charAt(0)} +
+
+

{getFullName(user)}

+

@{user.username}

+
+
+
+
+ + {user.email} +
+
+
+ + {getRole(user)} +
+
+ {user.isActive ? ( + +
+ نشط +
+ ) : ( + +
+ معطل +
+ )} +
+
+ + {formatDate(user.lastLogin)} +
+
+
+ + + {deleteConfirm === user.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ + {/* Pagination */} +
+

+ عرض{' '} + + {users.length ? (page - 1) * pageSize + 1 : 0}-{Math.min(page * pageSize, total)} + {' '} + من {total} مستخدم +

+
+ + {Array.from({ length: Math.min(5, Math.ceil(total / pageSize) || 1) }, (_, i) => i + 1).map( + (n) => ( + + ) + )} + +
+
+ + )}
{/* Add User Modal */} - {showAddModal && ( -
-
-
-

إضافة مستخدم جديد

-
- -
-
-
- - -
-
- - -
-
+ setShowAddModal(false)} + onSuccess={() => { + setShowAddModal(false); + fetchUsers(); + fetchStats(); + }} + employees={employees} + saving={saving} + setSaving={setSaving} + /> -
- - -
+ {/* Edit User Modal */} + { + setShowEditModal(false); + setEditingUser(null); + }} + onSuccess={() => { + setShowEditModal(false); + setEditingUser(null); + fetchUsers(); + fetchStats(); + }} + employees={employees} + saving={saving} + setSaving={setSaving} + /> -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- -
- - -
-
-
- )}
- ) + ); } +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({ + 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 ( + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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="••••••••" + /> +
+
+ setForm((f) => ({ ...f, isActive: e.target.checked }))} + className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + +
+
+ + +
+
+
+ ); +} + +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({ + 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 ( + + {user ? ( +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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="اتركه فارغاً للإبقاء على كلمة المرور الحالية" + /> +
+
+ setForm((f) => ({ ...f, isActive: e.target.checked }))} + className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + +
+
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 39f907b..1be6a58 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -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 { + success: boolean; + data: T[]; + pagination: { + total: number; + page: number; + pageSize: number; + totalPages: number; + }; } export const usersAPI = { - getAll: async (): Promise => { - const response = await api.get('/auth/users') - return response.data.data || response.data + getAll: async (filters?: UserFilters): Promise> => { + 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 => { - 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 => { - 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 => { - 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 => { + const response = await api.patch(`/admin/users/${id}/toggle-active`); + return response.data.data; }, delete: async (id: string): Promise => { - 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 => { + 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 => { + const response = await api.get('/admin/positions'); + return response.data.data || []; + }, + + updatePermissions: async ( + positionId: string, + permissions: Array<{ module: string; resource: string; actions: string[] }> + ): Promise => { + 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 => { - 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 => { - 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 => { + 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 => { - 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) - - const response = await api.get(`/admin/audit-logs?${params.toString()}`) - return response.data.data || [] - } -} + getAll: async (filters?: AuditLogFilters): Promise> => { + 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)); -// System Settings API + 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 (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 => { - 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 => { - const response = await api.put(`/admin/settings/${key}`, { value }) - return response.data.data - } -} + update: async (key: string, value: unknown): Promise => { + 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 => { - 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 || {}; + }, +};