Files
zerp/frontend/src/app/admin/permission-groups/page.tsx

372 lines
15 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 { 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>
);
}