RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script
Made-with: Cursor
This commit is contained in:
371
frontend/src/app/admin/permission-groups/page.tsx
Normal file
371
frontend/src/app/admin/permission-groups/page.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react';
|
||||
import { permissionGroupsAPI } from '@/lib/api/admin';
|
||||
import type { PermissionGroup } 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(perm: { actions?: unknown } | undefined, action: string): boolean {
|
||||
if (!perm?.actions) return false;
|
||||
const actions = Array.isArray(perm.actions) ? perm.actions : [];
|
||||
return actions.includes('*') || actions.includes('all') || actions.includes(action);
|
||||
}
|
||||
|
||||
function buildPermissionsFromMatrix(matrix: Record<string, Record<string, boolean>>) {
|
||||
return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => {
|
||||
const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id);
|
||||
return {
|
||||
module: m.id,
|
||||
resource: '*',
|
||||
actions: actions.length === ACTIONS.length ? ['*'] : actions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatrixFromPermissions(permissions: { module: string; resource: string; actions: string[] }[]) {
|
||||
const matrix: Record<string, Record<string, boolean>> = {};
|
||||
for (const m of MODULES) {
|
||||
matrix[m.id] = {};
|
||||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||||
const hasAll = perm && (Array.isArray(perm.actions)
|
||||
? perm.actions.includes('*') || perm.actions.includes('all')
|
||||
: false);
|
||||
for (const a of ACTIONS) {
|
||||
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
export default function PermissionGroupsPage() {
|
||||
const [groups, setGroups] = useState<PermissionGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', nameAr: '', description: '' });
|
||||
type PermissionMatrix = Record<string, Record<string, boolean>>;
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<PermissionMatrix>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await permissionGroupsAPI.getAll();
|
||||
setGroups(list);
|
||||
if (selectedId && !list.find((g) => g.id === selectedId)) setSelectedId(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل المجموعات');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const currentGroup = groups.find((g) => g.id === selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGroup) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentGroup.permissions || []));
|
||||
}
|
||||
}, [currentGroup?.id, currentGroup?.permissions]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.name.trim()) {
|
||||
alert('الاسم مطلوب');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const group = await permissionGroupsAPI.create({
|
||||
name: createForm.name.trim(),
|
||||
nameAr: createForm.nameAr.trim() || undefined,
|
||||
description: createForm.description.trim() || undefined,
|
||||
});
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ name: '', nameAr: '', description: '' });
|
||||
await fetchGroups();
|
||||
setSelectedId(group.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإنشاء');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||||
await permissionGroupsAPI.updatePermissions(selectedId, permissions);
|
||||
setShowEditModal(false);
|
||||
fetchGroups();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الحفظ');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||||
setPermissionMatrix((prev) => ({
|
||||
...prev,
|
||||
[moduleId]: {
|
||||
...(prev[moduleId] || {}),
|
||||
[actionId]: !prev[moduleId]?.[actionId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">مجموعات الصلاحيات</h1>
|
||||
<p className="text-gray-600">مجموعات اختيارية تضيف صلاحيات إضافية للمستخدمين بغض النظر عن وظائفهم</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-md"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="font-semibold">إضافة مجموعة</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-600 p-12">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">المجموعات ({groups.length})</h2>
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
onClick={() => setSelectedId(g.id)}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`p-2 rounded-lg ${selectedId === g.id ? 'bg-blue-600' : 'bg-blue-100'}`}>
|
||||
<UsersRound className={`h-5 w-5 ${selectedId === g.id ? 'text-white' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{g.nameAr || g.name}</h3>
|
||||
<p className="text-xs text-gray-600">{g.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
<Users className="h-4 w-4 inline mr-1" />
|
||||
{g._count?.userRoles ?? 0} مستخدم
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedId(g.id);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{currentGroup ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-6">
|
||||
<div className="flex justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{currentGroup.nameAr || currentGroup.name}</h2>
|
||||
<p className="text-gray-600">{currentGroup.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
تعديل الصلاحيات
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-4">مصفوفة الصلاحيات</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold min-w-[200px]">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold">{m.name}</p>
|
||||
<p className="text-xs text-gray-600">{m.nameEn}</p>
|
||||
</td>
|
||||
{ACTIONS.map((a) => {
|
||||
const has = permissionMatrix[m.id]?.[a.id];
|
||||
return (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
has ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{has ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-12 text-center">
|
||||
<UsersRound className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">اختر مجموعة</h3>
|
||||
<p className="text-gray-600">أو أنشئ مجموعة جديدة لإضافة صلاحيات اختيارية للمستخدمين</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="e.g. Campaign Approver"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name (Arabic)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.nameAr}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, nameAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title={`تعديل صلاحيات: ${currentGroup?.nameAr || currentGroup?.name || ''}`}
|
||||
size="2xl"
|
||||
>
|
||||
{currentGroup && (
|
||||
<div>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="px-4 py-4 font-semibold">{m.name}</td>
|
||||
{ACTIONS.map((a) => (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePermission(m.id, a.id)}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
permissionMatrix[m.id]?.[a.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{permissionMatrix[m.id]?.[a.id] ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setShowEditModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
إلغاء
|
||||
</button>
|
||||
<button onClick={handleSavePermissions} disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user