fix roles view

This commit is contained in:
yotakii
2026-03-02 13:24:57 +03:00
parent 0b886e81f0
commit e74f872e92
7 changed files with 615 additions and 108 deletions

View File

@@ -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(

View File

@@ -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',

View File

@@ -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);
}
/**

View File

@@ -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,7 +49,9 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
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)
const hasAll =
perm &&
(Array.isArray(perm.actions)
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
: false);
for (const a of ACTIONS) {
@@ -63,14 +65,30 @@ export default function RolesManagement() {
const [roles, setRoles] = useState<PositionRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
// Edit modal (name + permissions)
const [showEditModal, setShowEditModal] = useState(false);
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
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<PositionRole | null>(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<number>(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<string, { id: string; label: string }>();
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() {
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1>
<p className="text-gray-600">إدارة أدوار المستخدمين ومصفوفة الصلاحيات</p>
</div>
{/* رجعنا زر الإنشاء */}
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold"
>
<Plus className="h-5 w-5" />
إضافة دور
</button>
</div>
{loading ? (
@@ -192,12 +307,8 @@ export default function RolesManagement() {
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<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 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.titleAr || role.title}</h3>
@@ -205,14 +316,25 @@ export default function RolesManagement() {
</div>
</div>
</div>
<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 ?? role._count?.employees ?? 0} مستخدم</span>
</div>
{/* Actions: Edit + Delete */}
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(role);
}}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
@@ -224,17 +346,6 @@ export default function RolesManagement() {
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(role);
}}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
@@ -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"
>
تعديل الصلاحيات
تعديل الاسم والصلاحيات
</button>
</div>
</div>
<div className="p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
<div className="overflow-x-auto">
@@ -275,6 +387,7 @@ export default function RolesManagement() {
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{MODULES.map((module) => (
<tr key={module.id} className="hover:bg-gray-50 transition-colors">
@@ -284,15 +397,14 @@ export default function RolesManagement() {
<p className="text-xs text-gray-600">{module.nameEn}</p>
</div>
</td>
{ACTIONS.map((action) => {
const hasPermission = permissionMatrix[module.id]?.[action.id];
return (
<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 text-white shadow-md'
: 'bg-gray-200 text-gray-500'
hasPermission ? '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" />}
@@ -318,6 +430,103 @@ export default function RolesManagement() {
</div>
)}
{/* Create Role Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة دور جديد"
size="lg"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="e.g. Sales representative"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<input
value={newTitleAr}
onChange={(e) => setNewTitleAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="مثال: مندوب مبيعات"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2">
<label className="block text-sm font-semibold text-gray-700 mb-1">القسم (Department)</label>
<select
value={newDepartmentId}
onChange={(e) => setNewDepartmentId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
>
{departmentOptions.length === 0 ? (
<option value="">لا يوجد أقسام متاحة</option>
) : (
departmentOptions.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))
)}
</select>
{departmentOptions.length === 0 && (
<p className="text-xs text-red-600 mt-1">
لا يوجد أقسام ضمن البيانات الحالية. (DepartmentId مطلوب لإنشاء الدور)
</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Level</label>
<input
type="number"
value={newLevel}
onChange={(e) => setNewLevel(parseInt(e.target.value || '1', 10))}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
min={1}
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Code (اختياري)</label>
<input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="e.g. SALES_REP (إذا تركته فاضي النظام يولّد تلقائياً)"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setShowCreateModal(false)}
className="px-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={creating}
>
إلغاء
</button>
<button
onClick={handleCreateRole}
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2"
disabled={creating}
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{creating ? 'جاري الإنشاء...' : 'إنشاء'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation Dialog */}
{showDeleteDialog && roleToDelete && (
<div className="fixed inset-0 z-50 overflow-y-auto">
@@ -376,15 +585,37 @@ export default function RolesManagement() {
</div>
)}
{/* Edit Permissions Modal */}
{/* Edit Modal: name + permissions */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
title={`تعديل: ${currentRole?.titleAr || currentRole?.title || ''}`}
size="2xl"
>
{currentRole && (
<div>
{/* Name edit */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<input
value={editTitleAr}
onChange={(e) => setEditTitleAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
{/* Permissions */}
<div className="overflow-x-auto mb-6">
<table className="w-full">
<thead>
@@ -397,12 +628,14 @@ export default function RolesManagement() {
))}
</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 (
@@ -424,18 +657,21 @@ export default function RolesManagement() {
</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"
disabled={saving}
>
إلغاء
</button>
<button
onClick={handleSavePermissions}
onClick={handleSaveRole}
disabled={saving}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
</button>
</div>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#2563EB"/>
<text x="64" y="82" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="800" fill="#FFFFFF">Z</text>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@@ -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 (
<html lang="en">
<body className={`${readexPro.variable} ${cairo.variable} font-readex`}>
<LanguageProvider>
<AuthProvider>
<Providers>{children}</Providers>
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
duration: 4000,
style: {
background: '#fff',
color: '#363636',
fontFamily: 'var(--font-readex)',
},
success: {
duration: 3000,
iconTheme: {
primary: '#10B981',
secondary: '#fff',
},
},
error: {
duration: 5000,
iconTheme: {
primary: '#EF4444',
secondary: '#fff',
},
},
}}
/>
</AuthProvider>
</LanguageProvider>
</body>
</html>
<div className="min-h-screen bg-gray-50 flex">
<aside className="w-64 bg-white border-l shadow-lg fixed h-full overflow-y-auto">
<div className="p-6 border-b">
<div className="flex items-center gap-3 mb-4">
<div className="bg-red-600 p-2 rounded-lg">
<Shield className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">لوحة الإدارة</h2>
<p className="text-xs text-gray-600">System Admin</p>
</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-xs font-semibold text-red-900">{user?.username}</p>
<p className="text-xs text-red-700">{user?.role?.name}</p>
</div>
</div>
<nav className="p-4">
{menuItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href, item.exact)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg mb-2 transition-all ${
active
? 'bg-red-600 text-white shadow-md'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{item.label}</span>
</Link>
)
})}
<hr className="my-4 border-gray-200" />
<Link
href="/dashboard"
className="flex items-center gap-3 px-4 py-3 rounded-lg mb-2 text-gray-700 hover:bg-gray-100 transition-all"
>
<Building2 className="h-5 w-5" />
<span className="font-medium">العودة للنظام</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 transition-all"
>
<LogOut className="h-5 w-5" />
<span className="font-medium">تسجيل الخروج</span>
</button>
</nav>
</aside>
<main className="mr-64 flex-1 p-8">
{children}
</main>
</div>
)
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute>
<AdminLayoutContent>{children}</AdminLayoutContent>
</ProtectedRoute>
)
}

View File

@@ -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<PositionRole[]> => {
const response = await api.get('/admin/positions');
return response.data.data || [];
},
create: async (payload: CreatePositionPayload): Promise<PositionRole> => {
const response = await api.post('/admin/positions', payload);
return response.data.data;
},
update: async (positionId: string, payload: UpdatePositionPayload): Promise<PositionRole> => {
const response = await api.put(`/admin/positions/${positionId}`, payload);
return response.data.data;
},
delete: async (positionId: string): Promise<void> => {
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;