Files
zerp/frontend/src/app/admin/roles/page.tsx
2026-03-02 10:44:23 +03:00

447 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}