diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 4bfaf4d..2080c18 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -134,6 +134,42 @@ class AdminController { } } + async createPosition(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.id; + const position = await adminService.createPosition( + { + title: req.body.title, + titleAr: req.body.titleAr, + departmentId: req.body.departmentId, + level: req.body.level, + code: req.body.code, + }, + userId + ); + res.status(201).json(ResponseFormatter.success(position)); + } catch (error) { + next(error); + } + } + + async updatePosition(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.id; + const position = await adminService.updatePosition( + req.params.id, + { + title: req.body.title, + titleAr: req.body.titleAr, + }, + userId + ); + res.json(ResponseFormatter.success(position)); + } catch (error) { + next(error); + } + } + async updatePositionPermissions(req: AuthRequest, res: Response, next: NextFunction) { try { const position = await adminService.updatePositionPermissions( diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts index 8ba4f95..ce84a7d 100644 --- a/backend/src/modules/admin/admin.routes.ts +++ b/backend/src/modules/admin/admin.routes.ts @@ -89,6 +89,34 @@ router.get( adminController.getPositions ); +// Create role +router.post( + '/positions', + authorize('admin', 'roles', 'create'), + [ + body('title').notEmpty().trim(), + body('titleAr').optional().isString().trim(), + body('departmentId').isUUID(), + body('level').optional().isInt({ min: 1 }), + body('code').optional().isString().trim(), + ], + validate, + adminController.createPosition +); + +// Update role name (title/titleAr) +router.put( + '/positions/:id', + authorize('admin', 'roles', 'update'), + [ + param('id').isUUID(), + body('title').optional().notEmpty().trim(), + body('titleAr').optional().isString().trim(), + ], + validate, + adminController.updatePosition +); + // Delete (soft delete) a role/position router.delete( '/positions/:id', diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index c7d0eac..453913d 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -39,6 +39,19 @@ export interface AuditLogFilters { pageSize?: number; } +export interface CreatePositionData { + title: string; + titleAr?: string; + departmentId: string; + level?: number; + code?: string; +} + +export interface UpdatePositionData { + title?: string; + titleAr?: string; +} + class AdminService { // ========== USERS ========== @@ -94,7 +107,7 @@ class AdminService { ]); const sanitized = users.map((u) => { - const { password: _, ...rest } = u; + const { password: _, ...rest } = u as any; return rest; }); @@ -124,7 +137,7 @@ class AdminService { throw new AppError(404, 'المستخدم غير موجود - User not found'); } - const { password: _, ...rest } = user; + const { password: _, ...rest } = user as any; return rest; } @@ -223,7 +236,7 @@ class AdminService { ...(data.email && { email: data.email }), ...(data.username && { username: data.username }), ...(data.isActive !== undefined && { isActive: data.isActive }), - ...(data.employeeId !== undefined && { employeeId: data.employeeId || null }), + ...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }), }; if (data.password && data.password.length >= 8) { @@ -242,7 +255,7 @@ class AdminService { }, }); - const { password: _, ...sanitized } = user; + const { password: _, ...sanitized } = user as any; await AuditLogger.log({ entityType: 'USER', @@ -273,7 +286,7 @@ class AdminService { }, }); - const { password: _, ...sanitized } = updated; + const { password: _, ...sanitized } = updated as any; await AuditLogger.log({ entityType: 'USER', @@ -380,7 +393,7 @@ class AdminService { const positions = await prisma.position.findMany({ where: { isActive: true }, include: { - department: { select: { name: true, nameAr: true } }, + department: { select: { id: true, name: true, nameAr: true } }, permissions: true, _count: { select: { @@ -406,6 +419,120 @@ class AdminService { return withUserCount; } + private async generateUniqueCode(base: string) { + const cleaned = (base || 'ROLE') + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 18) || 'ROLE'; + + for (let i = 0; i < 25; i++) { + const suffix = Math.floor(1000 + Math.random() * 9000); + const code = `${cleaned}_${suffix}`; + const exists = await prisma.position.findUnique({ where: { code } }); + if (!exists) return code; + } + + // fallback + return `${cleaned}_${Date.now()}`; + } + + async createPosition(data: CreatePositionData, createdById: string) { + const title = (data.title || '').trim(); + const titleAr = (data.titleAr || '').trim(); + + if (!title && !titleAr) { + throw new AppError(400, 'اسم الدور مطلوب - Role name is required'); + } + + const department = await prisma.department.findUnique({ + where: { id: data.departmentId }, + }); + + if (!department || !department.isActive) { + throw new AppError(400, 'القسم غير موجود - Department not found'); + } + + let code = (data.code || '').trim(); + if (code) { + code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); + const exists = await prisma.position.findUnique({ where: { code } }); + if (exists) { + throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists'); + } + } else { + code = await this.generateUniqueCode(title || titleAr || 'ROLE'); + } + + const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1; + + const created = await prisma.position.create({ + data: { + title: title || titleAr, + titleAr: titleAr || null, + code, + departmentId: data.departmentId, + level, + }, + }); + + await AuditLogger.log({ + entityType: 'POSITION', + entityId: created.id, + action: 'CREATE', + userId: createdById, + changes: { + created: { + title: created.title, + titleAr: created.titleAr, + code: created.code, + departmentId: created.departmentId, + level: created.level, + }, + }, + }); + + const all = await this.getPositions(); + return all.find((p: any) => p.id === created.id) || created; + } + + async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) { + const existing = await prisma.position.findUnique({ where: { id: positionId } }); + if (!existing) { + throw new AppError(404, 'الدور غير موجود - Position not found'); + } + + const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title; + const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || ''); + + const finalTitle = nextTitle || nextTitleAr; + if (!finalTitle) { + throw new AppError(400, 'اسم الدور مطلوب - Role name is required'); + } + + const updated = await prisma.position.update({ + where: { id: positionId }, + data: { + title: finalTitle, + titleAr: nextTitleAr ? nextTitleAr : null, + }, + }); + + await AuditLogger.log({ + entityType: 'POSITION', + entityId: positionId, + action: 'UPDATE', + userId: updatedById, + changes: { + before: { title: existing.title, titleAr: existing.titleAr }, + after: { title: updated.title, titleAr: updated.titleAr }, + }, + }); + + const all = await this.getPositions(); + return all.find((p: any) => p.id === positionId) || updated; + } + async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) { const position = await prisma.position.findUnique({ where: { id: positionId } }); if (!position) { @@ -427,7 +554,7 @@ class AdminService { }); } - return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position); + return this.getPositions().then((pos: any) => pos.find((p: any) => p.id === positionId) || position); } /** diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index 79ed14d..281db70 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import { Shield, Edit, Trash2, Users, Check, X, Loader2 } from 'lucide-react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Shield, Edit, Trash2, Users, Check, X, Loader2, Plus } from 'lucide-react'; import { positionsAPI } from '@/lib/api/admin'; import type { PositionRole, PositionPermission } from '@/lib/api/admin'; import Modal from '@/components/Modal'; @@ -49,9 +49,11 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record 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); + 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); } @@ -63,14 +65,30 @@ export default function RolesManagement() { const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedRoleId, setSelectedRoleId] = useState(null); + + // Edit modal (name + permissions) const [showEditModal, setShowEditModal] = useState(false); const [permissionMatrix, setPermissionMatrix] = useState>>({}); + const [editTitle, setEditTitle] = useState(''); + const [editTitleAr, setEditTitleAr] = useState(''); const [saving, setSaving] = useState(false); + + // Delete dialog const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleting, setDeleting] = useState(false); const [roleToDelete, setRoleToDelete] = useState(null); + // Create modal + const [showCreateModal, setShowCreateModal] = useState(false); + const [creating, setCreating] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newTitleAr, setNewTitleAr] = useState(''); + const [newDepartmentId, setNewDepartmentId] = useState(''); + const [newLevel, setNewLevel] = useState(1); + const [newCode, setNewCode] = useState(''); + const fetchRoles = useCallback(async () => { setLoading(true); setError(null); @@ -93,11 +111,38 @@ export default function RolesManagement() { const currentRole = roles.find((r) => r.id === selectedRoleId); + // build departments options from existing roles + const departmentOptions = useMemo(() => { + const map = new Map(); + roles.forEach((r) => { + if (!r.departmentId) return; + const label = r.department?.nameAr || r.department?.name || r.departmentId; + if (!map.has(r.departmentId)) map.set(r.departmentId, { id: r.departmentId, label }); + }); + return Array.from(map.values()); + }, [roles]); + useEffect(() => { if (currentRole) { setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || [])); + setEditTitle(currentRole.title || ''); + setEditTitleAr(currentRole.titleAr || ''); } - }, [currentRole?.id, currentRole?.permissions]); + }, [currentRole?.id]); + + useEffect(() => { + // default department when opening create + if (!showCreateModal) return; + const fallback = + (selectedRoleId && roles.find(r => r.id === selectedRoleId)?.departmentId) || + departmentOptions[0]?.id || + ''; + setNewDepartmentId(fallback); + setNewLevel(1); + setNewCode(''); + setNewTitle(''); + setNewTitleAr(''); + }, [showCreateModal, selectedRoleId, roles, departmentOptions]); const handleTogglePermission = (moduleId: string, actionId: string) => { setPermissionMatrix((prev) => ({ @@ -109,16 +154,38 @@ export default function RolesManagement() { })); }; - const handleSavePermissions = async () => { + const handleSaveRole = async () => { if (!selectedRoleId) return; + + const titleFinal = (editTitle || '').trim() || (editTitleAr || '').trim(); + if (!titleFinal) { + alert('الرجاء إدخال اسم الدور'); + return; + } + setSaving(true); try { + // 1) update name (only if changed) + if (currentRole && (titleFinal !== currentRole.title || (editTitleAr || '') !== (currentRole.titleAr || ''))) { + await positionsAPI.update(selectedRoleId, { + title: titleFinal, + titleAr: (editTitleAr || '').trim() || null, + }); + } + + // 2) update permissions const permissions = buildPermissionsFromMatrix(permissionMatrix); await positionsAPI.updatePermissions(selectedRoleId, permissions); + setShowEditModal(false); - fetchRoles(); + await fetchRoles(); } catch (err: unknown) { - alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات'); + const msg = + (err as any)?.response?.data?.message || + (err as any)?.response?.data?.error || + (err as any)?.message || + 'فشل حفظ التغييرات'; + alert(msg); } finally { setSaving(false); } @@ -154,6 +221,45 @@ export default function RolesManagement() { } }; + const handleCreateRole = async () => { + const titleFinal = (newTitle || '').trim() || (newTitleAr || '').trim(); + if (!titleFinal) { + alert('الرجاء إدخال اسم الدور'); + return; + } + if (!newDepartmentId) { + alert('الرجاء اختيار قسم (Department)'); + return; + } + + setCreating(true); + try { + const created = await positionsAPI.create({ + title: titleFinal, + titleAr: (newTitleAr || '').trim() || null, + departmentId: newDepartmentId, + level: Number.isFinite(newLevel) ? newLevel : 1, + code: (newCode || '').trim() || undefined, + }); + + setShowCreateModal(false); + await fetchRoles(); + + // select new role + open edit modal directly + setSelectedRoleId(created.id); + setShowEditModal(true); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as any)?.response?.data?.error || + (err as any)?.message || + 'فشل إنشاء الدور'; + alert(msg); + } finally { + setCreating(false); + } + }; + const handleSelectRole = (id: string) => { setSelectedRoleId(id); setShowEditModal(false); @@ -166,6 +272,15 @@ export default function RolesManagement() {

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

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

+ + {/* رجعنا زر الإنشاء */} + {loading ? ( @@ -192,12 +307,8 @@ export default function RolesManagement() { >
-
- +
+

{role.titleAr || role.title}

@@ -205,14 +316,25 @@ export default function RolesManagement() {
+
{role.usersCount ?? role._count?.employees ?? 0} مستخدم
- {/* Actions: Edit + Delete */}
+ + - -
@@ -255,10 +366,11 @@ export default function RolesManagement() { onClick={() => setShowEditModal(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium" > - تعديل الصلاحيات + تعديل الاسم والصلاحيات +

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

@@ -275,6 +387,7 @@ export default function RolesManagement() { ))} + {MODULES.map((module) => ( @@ -284,15 +397,14 @@ export default function RolesManagement() {

{module.nameEn}

+ {ACTIONS.map((action) => { const hasPermission = permissionMatrix[module.id]?.[action.id]; return (
{hasPermission ? : } @@ -318,6 +430,103 @@ export default function RolesManagement() {
)} + {/* Create Role Modal */} + setShowCreateModal(false)} + title="إضافة دور جديد" + size="lg" + > +
+
+
+ + setNewTitle(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + placeholder="e.g. Sales representative" + /> +
+ +
+ + setNewTitleAr(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + placeholder="مثال: مندوب مبيعات" + /> +
+
+ +
+
+ + + {departmentOptions.length === 0 && ( +

+ لا يوجد أقسام ضمن البيانات الحالية. (DepartmentId مطلوب لإنشاء الدور) +

+ )} +
+ +
+ + setNewLevel(parseInt(e.target.value || '1', 10))} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + min={1} + /> +
+
+ +
+ + setNewCode(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + placeholder="e.g. SALES_REP (إذا تركته فاضي النظام يولّد تلقائياً)" + /> +
+ +
+ + +
+
+
+ {/* Delete Confirmation Dialog */} {showDeleteDialog && roleToDelete && (
@@ -376,15 +585,37 @@ export default function RolesManagement() {
)} - {/* Edit Permissions Modal */} + {/* Edit Modal: name + permissions */} setShowEditModal(false)} - title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`} + title={`تعديل: ${currentRole?.titleAr || currentRole?.title || ''}`} size="2xl" > {currentRole && (
+ {/* Name edit */} +
+
+ + setEditTitle(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+ +
+ + setEditTitleAr(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+
+ + {/* Permissions */}
@@ -397,12 +628,14 @@ export default function RolesManagement() { ))} + {MODULES.map((module) => ( + {ACTIONS.map((action) => { const hasPermission = permissionMatrix[module.id]?.[action.id]; return ( @@ -424,18 +657,21 @@ export default function RolesManagement() {

{module.name}

+
diff --git a/frontend/src/app/icon.svg b/frontend/src/app/icon.svg new file mode 100644 index 0000000..ce03e09 --- /dev/null +++ b/frontend/src/app/icon.svg @@ -0,0 +1,4 @@ + + + Z + \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index e2031b8..c30bc2e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,69 +1,120 @@ -import type { Metadata } from 'next' -import { Cairo, Readex_Pro } from 'next/font/google' -import './globals.css' -import { Providers } from './providers' -import { AuthProvider } from '@/contexts/AuthContext' -import { LanguageProvider } from '@/contexts/LanguageContext' -import { Toaster } from 'react-hot-toast' +'use client' -const cairo = Cairo({ - subsets: ['latin', 'arabic'], - variable: '--font-cairo', - display: 'swap', -}) +import ProtectedRoute from '@/components/ProtectedRoute' +import { useAuth } from '@/contexts/AuthContext' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + Users, + Shield, + Database, + Settings, + FileText, + Activity, + Mail, + Key, + Clock, + Building2, + LogOut, + LayoutDashboard, + Users2 +} from 'lucide-react' -const readexPro = Readex_Pro({ - subsets: ['latin', 'arabic'], - variable: '--font-readex', - display: 'swap', -}) +function AdminLayoutContent({ children }: { children: React.ReactNode }) { + const { user, logout } = useAuth() + const pathname = usePathname() -export const metadata: Metadata = { - title: 'Z.CRM - نظام إدارة علاقات العملاء', - description: 'Enterprise CRM System for Contact Management, Sales, HR, Inventory, Projects, and Marketing', -} + const menuItems = [ + { icon: LayoutDashboard, label: 'لوحة التحكم', href: '/admin', exact: true }, + { icon: Users, label: 'إدارة المستخدمين', href: '/admin/users' }, + { icon: Shield, label: 'الأدوار والصلاحيات', href: '/admin/roles' }, + { icon: Users2, label: 'مجموعات الصلاحيات', href: '/admin/permission-groups' }, + { icon: Database, label: 'النسخ الاحتياطي', href: '/admin/backup' }, + { icon: Settings, label: 'إعدادات النظام', href: '/admin/settings' }, + { icon: FileText, label: 'سجل العمليات', href: '/admin/audit-logs' }, + { icon: Activity, label: 'صحة النظام', href: '/admin/health' }, + { icon: Mail, label: 'إعدادات البريد', href: '/admin/email' }, + { icon: Key, label: 'مفاتيح API', href: '/admin/api-keys' }, + { icon: Clock, label: 'المهام المجدولة', href: '/admin/scheduled-jobs' } + ] + + const isActive = (href: string, exact?: boolean) => { + if (exact) { + return pathname === href + } + return pathname.startsWith(href) + } -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { return ( - - - - - {children} - - - - - +
+ + +
+ {children} +
+
) } +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 6cd7bf1..85126a5 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -113,7 +113,7 @@ export const statsAPI = { }, }; -// Positions (Roles) API - maps to HR positions with permissions +// Positions (Roles) API export interface PositionPermission { id: string; module: string; @@ -126,18 +126,43 @@ export interface PositionRole { title: string; titleAr?: string | null; code: string; - department?: { name: string; nameAr?: string | null }; + level: number; + departmentId: string; + department?: { id?: string; name: string; nameAr?: string | null }; permissions: PositionPermission[]; usersCount: number; _count?: { employees: number }; } +export interface CreatePositionPayload { + title: string; + titleAr?: string | null; + departmentId: string; + level?: number; + code?: string; +} + +export interface UpdatePositionPayload { + title?: string; + titleAr?: string | null; +} + export const positionsAPI = { getAll: async (): Promise => { const response = await api.get('/admin/positions'); return response.data.data || []; }, + create: async (payload: CreatePositionPayload): Promise => { + const response = await api.post('/admin/positions', payload); + return response.data.data; + }, + + update: async (positionId: string, payload: UpdatePositionPayload): Promise => { + const response = await api.put(`/admin/positions/${positionId}`, payload); + return response.data.data; + }, + delete: async (positionId: string): Promise => { await api.delete(`/admin/positions/${positionId}`); }, @@ -153,7 +178,7 @@ export const positionsAPI = { }, }; -// Roles API - alias for positions (for compatibility with existing frontend) +// Roles API - alias for positions export interface Role { id: string; name: string; @@ -235,7 +260,7 @@ export const auditLogsAPI = { }, }; -// System Settings API (placeholder - out of scope) +// System Settings API (placeholder) export interface SystemSetting { key: string; value: unknown; @@ -254,7 +279,7 @@ export const settingsAPI = { }, }; -// System Health API (placeholder - optional) +// System Health API (placeholder) export interface SystemHealth { status: string; database: string;