447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { Shield, Edit, Trash2, Users, Check, X, Loader2 } from 'lucide-react';
|
||
import { positionsAPI } from '@/lib/api/admin';
|
||
import type { PositionRole, PositionPermission } from '@/lib/api/admin';
|
||
import Modal from '@/components/Modal';
|
||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||
|
||
const MODULES = [
|
||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||
];
|
||
|
||
const ACTIONS = [
|
||
{ id: 'read', name: 'عرض' },
|
||
{ id: 'create', name: 'إنشاء' },
|
||
{ id: 'update', name: 'تعديل' },
|
||
{ id: 'delete', name: 'حذف' },
|
||
{ id: 'export', name: 'تصدير' },
|
||
{ id: 'approve', name: 'اعتماد' },
|
||
{ id: 'merge', name: 'دمج' },
|
||
];
|
||
|
||
function hasAction(permission: PositionPermission | undefined, action: string): boolean {
|
||
if (!permission?.actions) return false;
|
||
const actions = Array.isArray(permission.actions) ? permission.actions : [];
|
||
return actions.includes('*') || actions.includes('all') || actions.includes(action);
|
||
}
|
||
|
||
function buildPermissionsFromMatrix(matrix: Record<string, Record<string, boolean>>) {
|
||
return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => {
|
||
const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id);
|
||
return {
|
||
module: m.id,
|
||
resource: '*',
|
||
actions: actions.length === ACTIONS.length ? ['*'] : actions,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<string, Record<string, boolean>> {
|
||
const matrix: Record<string, Record<string, boolean>> = {};
|
||
for (const m of MODULES) {
|
||
matrix[m.id] = {};
|
||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||
const hasAll = perm && (Array.isArray(perm.actions)
|
||
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
|
||
: false);
|
||
for (const a of ACTIONS) {
|
||
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
|
||
}
|
||
}
|
||
return matrix;
|
||
}
|
||
|
||
export default function RolesManagement() {
|
||
const [roles, setRoles] = useState<PositionRole[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||
const [saving, setSaving] = useState(false);
|
||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [roleToDelete, setRoleToDelete] = useState<PositionRole | null>(null);
|
||
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
fetchRoles();
|
||
}, [fetchRoles]);
|
||
|
||
const currentRole = roles.find((r) => r.id === selectedRoleId);
|
||
|
||
useEffect(() => {
|
||
if (currentRole) {
|
||
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
|
||
}
|
||
}, [currentRole?.id, currentRole?.permissions]);
|
||
|
||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||
setPermissionMatrix((prev) => ({
|
||
...prev,
|
||
[moduleId]: {
|
||
...(prev[moduleId] || {}),
|
||
[actionId]: !prev[moduleId]?.[actionId],
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handleSavePermissions = async () => {
|
||
if (!selectedRoleId) return;
|
||
setSaving(true);
|
||
try {
|
||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||
await positionsAPI.updatePermissions(selectedRoleId, permissions);
|
||
setShowEditModal(false);
|
||
fetchRoles();
|
||
} catch (err: unknown) {
|
||
alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const openDeleteDialog = (role: PositionRole) => {
|
||
setRoleToDelete(role);
|
||
setShowDeleteDialog(true);
|
||
};
|
||
|
||
const handleDeleteRole = async () => {
|
||
if (!roleToDelete) return;
|
||
setDeleting(true);
|
||
try {
|
||
await positionsAPI.delete(roleToDelete.id);
|
||
|
||
if (selectedRoleId === roleToDelete.id) {
|
||
setSelectedRoleId(null);
|
||
}
|
||
|
||
setShowDeleteDialog(false);
|
||
setRoleToDelete(null);
|
||
await fetchRoles();
|
||
} catch (err: unknown) {
|
||
const msg =
|
||
(err as any)?.response?.data?.message ||
|
||
(err as any)?.response?.data?.error ||
|
||
(err as any)?.message ||
|
||
'فشل حذف الدور';
|
||
alert(msg);
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
const handleSelectRole = (id: string) => {
|
||
setSelectedRoleId(id);
|
||
setShowEditModal(false);
|
||
};
|
||
|
||
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>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex justify-center p-12">
|
||
<LoadingSpinner />
|
||
</div>
|
||
) : error ? (
|
||
<div className="text-center text-red-600 p-12">{error}</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
{/* Roles List */}
|
||
<div className="lg:col-span-1 space-y-4">
|
||
<h2 className="text-xl font-bold text-gray-900 mb-4">الأدوار ({roles.length})</h2>
|
||
|
||
{roles.map((role) => (
|
||
<div
|
||
key={role.id}
|
||
onClick={() => 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'
|
||
}`}
|
||
>
|
||
<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>
|
||
<div>
|
||
<h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3>
|
||
<p className="text-xs text-gray-600">{role.title}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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();
|
||
setSelectedRoleId(role.id);
|
||
setShowEditModal(true);
|
||
}}
|
||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||
title="تعديل"
|
||
>
|
||
<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>
|
||
))}
|
||
</div>
|
||
|
||
{/* Permission Matrix */}
|
||
<div className="lg:col-span-2">
|
||
{currentRole ? (
|
||
<div className="bg-white rounded-xl shadow-lg border border-gray-200">
|
||
<div className="p-6 border-b border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900">{currentRole.titleAr || currentRole.title}</h2>
|
||
<p className="text-gray-600">{currentRole.title}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowEditModal(true)}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||
>
|
||
تعديل الصلاحيات
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="p-6">
|
||
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b-2 border-gray-200">
|
||
<th className="px-4 py-3 text-right text-sm font-bold text-gray-900 min-w-[200px]">
|
||
الوحدة
|
||
</th>
|
||
{ACTIONS.map((perm) => (
|
||
<th key={perm.id} className="px-4 py-3 text-center text-sm font-bold text-gray-900">
|
||
{perm.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{MODULES.map((module) => (
|
||
<tr key={module.id} className="hover:bg-gray-50 transition-colors">
|
||
<td className="px-4 py-4">
|
||
<div>
|
||
<p className="font-semibold text-gray-900">{module.name}</p>
|
||
<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 ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||
</div>
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-12 text-center">
|
||
<Shield 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>
|
||
)}
|
||
|
||
{/* Delete Confirmation Dialog */}
|
||
{showDeleteDialog && roleToDelete && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50"
|
||
onClick={() => {
|
||
if (!deleting) {
|
||
setShowDeleteDialog(false);
|
||
setRoleToDelete(null);
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex min-h-screen items-center justify-center p-4">
|
||
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
|
||
<div className="flex items-center gap-4 mb-4">
|
||
<div className="bg-red-100 p-3 rounded-full">
|
||
<Trash2 className="h-6 w-6 text-red-600" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-bold text-gray-900">حذف الدور</h3>
|
||
<p className="text-sm text-gray-600">هذا الإجراء لا يمكن التراجع عنه</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-gray-700 mb-6">
|
||
هل أنت متأكد أنك تريد حذف دور{' '}
|
||
<span className="font-semibold">{roleToDelete.titleAr || roleToDelete.title}</span>؟
|
||
</p>
|
||
<div className="flex items-center justify-end gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setShowDeleteDialog(false);
|
||
setRoleToDelete(null);
|
||
}}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||
disabled={deleting}
|
||
>
|
||
إلغاء
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteRole}
|
||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||
disabled={deleting}
|
||
>
|
||
{deleting ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
جاري الحذف...
|
||
</>
|
||
) : (
|
||
'حذف'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Permissions Modal */}
|
||
<Modal
|
||
isOpen={showEditModal}
|
||
onClose={() => setShowEditModal(false)}
|
||
title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
|
||
size="2xl"
|
||
>
|
||
{currentRole && (
|
||
<div>
|
||
<div className="overflow-x-auto mb-6">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b-2 border-gray-200">
|
||
<th className="px-4 py-3 text-right text-sm font-bold text-gray-900">الوحدة</th>
|
||
{ACTIONS.map((perm) => (
|
||
<th key={perm.id} className="px-4 py-3 text-center text-sm font-bold text-gray-900">
|
||
{perm.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{MODULES.map((module) => (
|
||
<tr key={module.id}>
|
||
<td className="px-4 py-4">
|
||
<p className="font-semibold text-gray-900">{module.name}</p>
|
||
</td>
|
||
{ACTIONS.map((action) => {
|
||
const hasPermission = permissionMatrix[module.id]?.[action.id];
|
||
return (
|
||
<td key={action.id} className="px-4 py-4 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleTogglePermission(module.id, action.id)}
|
||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${
|
||
hasPermission ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||
}`}
|
||
>
|
||
{hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||
</button>
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex gap-3 justify-end">
|
||
<button
|
||
onClick={() => setShowEditModal(false)}
|
||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
|
||
>
|
||
إلغاء
|
||
</button>
|
||
<button
|
||
onClick={handleSavePermissions}
|
||
disabled={saving}
|
||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
|
||
>
|
||
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
} |