Files
zerp/frontend/src/app/admin/roles/page.tsx

512 lines
22 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, Users, Check, X, Plus } from 'lucide-react';
import { positionsAPI } from '@/lib/api/admin';
import { departmentsAPI } from '@/lib/api/employees';
import type { PositionRole, PositionPermission, CreatePositionData } from '@/lib/api/admin';
import Modal from '@/components/Modal';
import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'leave_requests', name: 'طلبات الإجازات', nameEn: 'Leave Requests' },
{ id: 'overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Overtime Requests' },
{ id: 'loan_requests', name: 'طلبات القروض', nameEn: 'Loan Requests' },
{ id: 'purchase_requests', name: 'طلبات الشراء', nameEn: 'Purchase Requests' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية للقسم', nameEn: 'Department Overtime Requests' },
{ id: 'department_expense_claims', name: 'طلبات كشف المصاريف للقسم', nameEn: 'Department Expense Claims' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ 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: 'mark-as-paid', name: 'تأكيد القبض' },
{ id: 'notify', 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;
}
const initialCreateForm: CreatePositionData & { description?: string } = {
title: '',
titleAr: '',
code: '',
departmentId: '',
level: 5,
description: '',
};
export default function RolesManagement() {
const [roles, setRoles] = useState<PositionRole[]>([]);
const [departments, setDepartments] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
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 [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState(initialCreateForm);
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
const [saving, setSaving] = useState(false);
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]);
useEffect(() => {
departmentsAPI.getAll().then((depts) => setDepartments(depts)).catch(() => {});
}, []);
const currentRole = roles.find((r) => r.id === selectedRoleId);
const handleCreateRole = async (e: React.FormEvent) => {
e.preventDefault();
const errs: Record<string, string> = {};
if (!createForm.title?.trim()) errs.title = 'Required';
if (!createForm.code?.trim()) errs.code = 'Required';
if (!createForm.departmentId) errs.departmentId = 'Required';
setCreateErrors(errs);
if (Object.keys(errs).length > 0) return;
setSaving(true);
try {
const position = await positionsAPI.create({
title: createForm.title.trim(),
titleAr: createForm.titleAr?.trim() || undefined,
code: createForm.code.trim(),
departmentId: createForm.departmentId,
level: createForm.level ?? 5,
description: createForm.description?.trim() || undefined,
});
setShowCreateModal(false);
setCreateForm(initialCreateForm);
setCreateErrors({});
await fetchRoles();
setSelectedRoleId(position.id);
setShowEditModal(true);
} catch (err: unknown) {
setCreateErrors({ form: err instanceof Error ? err.message : 'Failed to create role' });
} finally {
setSaving(false);
}
};
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 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>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all shadow-md hover:shadow-lg"
>
<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">
{/* 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>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedRoleId(role.id);
setShowEditModal(true);
}}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
<Edit className="h-4 w-4" />
</button>
</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>
)}
{/* Create Role Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false);
setCreateForm(initialCreateForm);
setCreateErrors({});
}}
title="إضافة دور جديد"
size="md"
>
<form onSubmit={handleCreateRole} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (English) *</label>
<input
type="text"
value={createForm.title}
onChange={(e) => setCreateForm((p) => ({ ...p, title: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="e.g. Sales Representative"
/>
{createErrors.title && <p className="text-red-500 text-xs mt-1">{createErrors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Arabic)</label>
<input
type="text"
value={createForm.titleAr || ''}
onChange={(e) => setCreateForm((p) => ({ ...p, titleAr: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="مندوب مبيعات"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
<input
type="text"
value={createForm.code}
onChange={(e) => setCreateForm((p) => ({ ...p, code: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="SALES_REP"
/>
{createErrors.code && <p className="text-red-500 text-xs mt-1">{createErrors.code}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department *</label>
<select
value={createForm.departmentId}
onChange={(e) => setCreateForm((p) => ({ ...p, departmentId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="">Select department</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>{d.nameAr || d.name}</option>
))}
</select>
{createErrors.departmentId && <p className="text-red-500 text-xs mt-1">{createErrors.departmentId}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
<input
type="number"
min={1}
value={createForm.level ?? 5}
onChange={(e) => setCreateForm((p) => ({ ...p, level: parseInt(e.target.value, 10) || 5 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</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 focus:ring-2 focus:ring-purple-500"
rows={2}
placeholder="Optional description"
/>
</div>
{createErrors.form && <p className="text-red-500 text-sm">{createErrors.form}</p>}
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
>
{saving ? 'Creating...' : 'Create Role'}
</button>
</div>
</form>
</Modal>
{/* 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>
);
}