Commit c15edf88 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add permission matrix page for visual role-permission management

New /roles/matrix page shows all permissions as rows grouped by module
with Arabic labels, all roles as columns, and color-coded scope badges
(own/branch/academy/all). Includes module and role filtering, stats
per role, and click-to-cycle scope for custom roles. System roles are
read-only. Accessible from the roles list page via "المصفوفة" button.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ea896bb3
<?php
namespace App\Livewire\Roles;
use App\Domain\Identity\Models\Permission;
use App\Domain\Identity\Models\Role;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('مصفوفة الصلاحيات')]
class PermissionMatrix extends Component
{
public string $filterModule = '';
public string $filterRole = '';
public function mount(): void
{
$this->authorize('roles.list');
}
public function togglePermission(int $roleId, int $permissionId, string $currentScope): void
{
$this->authorize('roles.assign_permissions');
$role = Role::find($roleId);
if (!$role || $role->is_system) {
session()->flash('error', __('لا يمكن تعديل صلاحيات دور النظام'));
return;
}
$scopes = ['', 'own', 'own_groups', 'branch', 'academy', 'all'];
$currentIndex = array_search($currentScope, $scopes);
$nextIndex = ($currentIndex + 1) % count($scopes);
$nextScope = $scopes[$nextIndex];
if ($nextScope === '') {
$role->permissions()->detach($permissionId);
} else {
$exists = DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->exists();
if ($exists) {
DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->update(['scope' => $nextScope]);
} else {
DB::table('permission_role')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'scope' => $nextScope,
'created_at' => now(),
]);
}
}
}
public function render()
{
$roles = Role::where('academy_id', app('current_academy')->id)
->orderByDesc('level')
->get();
$permissionsQuery = Permission::orderBy('module')->orderBy('action');
if ($this->filterModule) {
$permissionsQuery->where('module', $this->filterModule);
}
$permissions = $permissionsQuery->get();
$grouped = $permissions->groupBy('module');
$modules = Permission::distinct()->orderBy('module')->pluck('module');
// Build pivot map: role_id => [permission_id => scope]
$pivotData = DB::table('permission_role')
->whereIn('role_id', $roles->pluck('id'))
->get()
->groupBy('role_id')
->map(fn ($items) => $items->pluck('scope', 'permission_id')->toArray())
->toArray();
if ($this->filterRole) {
$roles = $roles->filter(fn ($r) => $r->slug === $this->filterRole)->values();
}
return view('livewire.roles.permission-matrix', [
'roles' => $roles,
'grouped' => $grouped,
'modules' => $modules,
'pivotData' => $pivotData,
'moduleLabels' => $this->getModuleLabels(),
'scopeLabels' => $this->getScopeLabels(),
]);
}
private function getModuleLabels(): array
{
return [
'dashboard' => 'لوحة التحكم',
'academies' => 'الأكاديمية',
'branches' => 'الفروع',
'users' => 'المستخدمين',
'roles' => 'الأدوار',
'people' => 'الأشخاص',
'participants' => 'المشتركين',
'guardians' => 'أولياء الأمور',
'activities' => 'الأنشطة',
'programs' => 'البرامج',
'groups' => 'المجموعات',
'sessions' => 'الحصص',
'schedules' => 'الجداول',
'enrollments' => 'التسجيلات',
'holidays' => 'العطلات',
'waitlists' => 'قوائم الانتظار',
'attendance' => 'الحضور',
'excuses' => 'الأعذار',
'invoices' => 'الفواتير',
'payments' => 'المدفوعات',
'transactions' => 'المعاملات المالية',
'accounts' => 'الحسابات',
'wallets' => 'المحافظ',
'payment_plans' => 'خطط الدفع',
'cash_sessions' => 'ورديات الكاشير',
'refunds' => 'المرتجعات',
'daily_closing' => 'الإقفال اليومي',
'expenses' => 'المصروفات',
'pricing' => 'التسعير',
'base_prices' => 'الأسعار الأساسية',
'pricing_rules' => 'قواعد التسعير',
'promotions' => 'العروض',
'coupons' => 'الكوبونات',
'pos' => 'نقطة البيع',
'pos_sessions' => 'ورديات نقطة البيع',
'products' => 'المنتجات',
'categories' => 'التصنيفات',
'warehouses' => 'المخازن',
'inventory' => 'المخزون',
'purchase_orders' => 'أوامر الشراء',
'stock_counts' => 'جرد المخزون',
'kits' => 'الأطقم',
'suppliers' => 'الموردين',
'facilities' => 'المنشآت',
'reservations' => 'الحجوزات',
'employees' => 'الموظفين',
'trainers' => 'المدربين',
'payroll' => 'الرواتب',
'assignments' => 'التكليفات',
'notifications' => 'الإشعارات',
'notification_preferences' => 'إعدادات الإشعارات',
'reports' => 'التقارير',
'approvals' => 'الموافقات',
'settings' => 'الإعدادات',
'evaluations' => 'التقييمات',
'documents' => 'المستندات',
'audit' => 'سجل المراجعة',
'super_admin' => 'مدير النظام',
'events' => 'الفعاليات',
];
}
private function getScopeLabels(): array
{
return [
'own' => 'الخاصة',
'own_groups' => 'مجموعاته',
'own_children' => 'أبنائه',
'branch' => 'الفرع',
'academy' => 'الأكاديمية',
'all' => 'الكل',
];
}
public function getActionLabel(string $action): string
{
return match ($action) {
'list' => 'عرض القائمة',
'view' => 'عرض التفاصيل',
'show' => 'عرض',
'create' => 'إنشاء',
'update' => 'تعديل',
'delete' => 'حذف',
'export' => 'تصدير',
'import' => 'استيراد',
'cancel' => 'إلغاء',
'send' => 'إرسال',
'manage' => 'إدارة',
'mark' => 'تسجيل',
'edit' => 'تحرير',
'view_reports' => 'عرض التقارير',
'submit' => 'تقديم',
'approve' => 'موافقة',
'reject' => 'رفض',
'change_status' => 'تغيير الحالة',
'view_financial' => 'عرض المالي',
'view_attendance' => 'عرض الحضور',
'bulk_status_change' => 'تغيير حالة جماعي',
'manage_enrollments' => 'إدارة التسجيلات',
'reschedule' => 'إعادة جدولة',
'start' => 'بدء',
'complete' => 'إكمال',
'generate' => 'إنشاء تلقائي',
'override' => 'تجاوز',
'transfer' => 'نقل',
'promote' => 'ترقية',
'refund' => 'استرداد',
'credit' => 'إيداع',
'debit' => 'خصم',
'freeze' => 'تجميد',
'open' => 'فتح',
'close' => 'إغلاق',
'initiate' => 'بدء',
'configure' => 'تهيئة',
'access' => 'الوصول',
'sell' => 'بيع',
'override_price' => 'تجاوز السعر',
'override_stock' => 'تجاوز المخزون',
'void_transaction' => 'إلغاء عملية',
'receive' => 'استلام',
'adjust' => 'تسوية',
'view_levels' => 'عرض المستويات',
'manage_layouts' => 'إدارة التخطيطات',
'manage_hours' => 'إدارة المواعيد',
'confirm' => 'تأكيد',
'manage_qualifications' => 'إدارة المؤهلات',
'manage_availability' => 'إدارة التوفر',
'view_compensation' => 'عرض المكافآت',
'pay' => 'صرف',
'manage_templates' => 'إدارة القوالب',
'financial' => 'التقارير المالية',
'attendance' => 'تقارير الحضور',
'enrollment' => 'تقارير التسجيل',
'inventory' => 'تقارير المخزون',
'trainer' => 'تقارير المدربين',
'facility' => 'تقارير المنشآت',
'export_pdf' => 'تصدير PDF',
'export_excel' => 'تصدير Excel',
'view_audit_log' => 'عرض سجل المراجعة',
'share' => 'مشاركة',
'upload' => 'رفع',
'merge' => 'دمج',
'assign_roles' => 'تعيين أدوار',
'assign_permissions' => 'تعيين صلاحيات',
'deactivate' => 'إلغاء التفعيل',
default => $action,
};
}
}
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('مصفوفة الصلاحيات') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('عرض وإدارة صلاحيات كل دور في النظام') }}</p>
</div>
<a href="{{ route('roles.list') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-medium">
&larr; {{ __('قائمة الأدوار') }}
</a>
</div>
{{-- Flash Messages --}}
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ session('error') }}</div>
@endif
{{-- Legend --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="flex flex-wrap items-center gap-4">
<span class="text-sm font-semibold text-gray-700">{{ __('مفتاح النطاقات:') }}</span>
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-600">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
{{ __('لا يوجد') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
<span class="w-2 h-2 rounded-full bg-yellow-500"></span>
{{ __('الخاصة') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-orange-100 text-orange-800">
<span class="w-2 h-2 rounded-full bg-orange-500"></span>
{{ __('مجموعاته') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">
<span class="w-2 h-2 rounded-full bg-purple-500"></span>
{{ __('أبنائه') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
{{ __('الفرع') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
{{ __('الأكاديمية') }}
</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
<span class="w-2 h-2 rounded-full bg-red-500"></span>
{{ __('الكل') }}
</span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">{{ __('اضغط على أي خلية لتدوير النطاق (لا يوجد ← الخاصة ← مجموعاته ← الفرع ← الأكاديمية ← الكل ← لا يوجد). الأدوار النظامية لا يمكن تعديلها.') }}</p>
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تصفية بالوحدة') }}</label>
<select wire:model.live="filterModule" class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل الوحدات') }}</option>
@foreach($modules as $mod)
<option value="{{ $mod }}">{{ $moduleLabels[$mod] ?? $mod }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تصفية بالدور') }}</label>
<select wire:model.live="filterRole" class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('كل الأدوار') }}</option>
@foreach($roles as $role)
<option value="{{ $role->slug }}">{{ $role->name_ar }}</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Matrix Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="sticky start-0 z-10 bg-gray-50 px-4 py-3 text-start text-xs font-semibold text-gray-600 min-w-[220px]">
{{ __('الصلاحية') }}
</th>
@foreach($roles as $role)
<th class="px-3 py-3 text-center text-xs font-semibold text-gray-600 min-w-[90px] whitespace-nowrap">
<div>{{ $role->name_ar }}</div>
<div class="font-normal text-[10px] text-gray-400">{{ $role->level }}</div>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($grouped as $module => $permissions)
{{-- Module header row --}}
<tr class="bg-gray-50/50">
<td colspan="{{ $roles->count() + 1 }}" class="px-4 py-2">
<span class="text-xs font-bold text-gray-800">{{ $moduleLabels[$module] ?? $module }}</span>
<span class="text-[10px] text-gray-400 ms-2 font-mono">({{ $module }})</span>
</td>
</tr>
@foreach($permissions as $permission)
<tr class="hover:bg-blue-50/30 transition-colors">
{{-- Permission name cell --}}
<td class="sticky start-0 z-10 bg-white px-4 py-2 border-e border-gray-100">
<div class="flex flex-col">
<span class="text-sm text-gray-800">{{ $this->getActionLabel($permission->action) }}</span>
<span class="text-[10px] text-gray-400 font-mono" dir="ltr">{{ $permission->name }}</span>
@if($permission->description_ar)
<span class="text-[10px] text-gray-500">{{ $permission->description_ar }}</span>
@endif
</div>
</td>
{{-- Role cells --}}
@foreach($roles as $role)
@php
$scope = $pivotData[$role->id][$permission->id] ?? '';
$scopeConfig = match($scope) {
'own' => ['bg' => 'bg-yellow-100', 'text' => 'text-yellow-800', 'dot' => 'bg-yellow-500', 'label' => 'الخاصة'],
'own_groups' => ['bg' => 'bg-orange-100', 'text' => 'text-orange-800', 'dot' => 'bg-orange-500', 'label' => 'مجموعاته'],
'own_children' => ['bg' => 'bg-purple-100', 'text' => 'text-purple-800', 'dot' => 'bg-purple-500', 'label' => 'أبنائه'],
'branch' => ['bg' => 'bg-blue-100', 'text' => 'text-blue-800', 'dot' => 'bg-blue-500', 'label' => 'الفرع'],
'academy' => ['bg' => 'bg-green-100', 'text' => 'text-green-800', 'dot' => 'bg-green-500', 'label' => 'الأكاديمية'],
'all' => ['bg' => 'bg-red-100', 'text' => 'text-red-800', 'dot' => 'bg-red-500', 'label' => 'الكل'],
default => ['bg' => 'bg-gray-50', 'text' => 'text-gray-400', 'dot' => 'bg-gray-300', 'label' => '—'],
};
@endphp
<td class="px-1 py-1.5 text-center">
@if($role->is_system)
<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium {{ $scopeConfig['bg'] }} {{ $scopeConfig['text'] }}"
title="{{ $scope ? ($scopeLabels[$scope] ?? $scope) : __('لا يوجد') }}">
<span class="w-1.5 h-1.5 rounded-full {{ $scopeConfig['dot'] }}"></span>
{{ $scopeConfig['label'] }}
</span>
@else
<button wire:click="togglePermission({{ $role->id }}, {{ $permission->id }}, '{{ $scope }}')"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium {{ $scopeConfig['bg'] }} {{ $scopeConfig['text'] }} hover:ring-2 hover:ring-blue-300 transition-all cursor-pointer"
title="{{ __('اضغط لتغيير النطاق') }}">
<span class="w-1.5 h-1.5 rounded-full {{ $scopeConfig['dot'] }}"></span>
{{ $scopeConfig['label'] }}
</button>
@endif
</td>
@endforeach
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Stats --}}
<div class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach($roles->take(8) as $role)
@php
$count = count($pivotData[$role->id] ?? []);
$total = $grouped->flatten()->count();
$pct = $total > 0 ? round($count / $total * 100) : 0;
@endphp
<div class="bg-white rounded-lg border border-gray-200 p-3">
<p class="text-xs text-gray-500">{{ $role->name_ar }}</p>
<p class="text-lg font-bold text-gray-800">{{ $count }} <span class="text-xs font-normal text-gray-400">/ {{ $total }}</span></p>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
</div>
<div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">الأدوار والصلاحيات</h1>
<div class="flex items-center gap-2">
<a href="{{ route('roles.matrix') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<span class="hidden sm:inline">{{ __('المصفوفة') }}</span>
</a>
@can('roles.create')
<a href="{{ route('roles.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
......@@ -9,6 +15,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-l
</a>
@endcan
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
@foreach($roles as $role)
......
......@@ -165,6 +165,8 @@
// Roles
Route::get('/roles', \App\Livewire\Roles\RoleList::class)->name('roles.list')
->middleware('permission:roles.list');
Route::get('/roles/matrix', \App\Livewire\Roles\PermissionMatrix::class)->name('roles.matrix')
->middleware('permission:roles.list');
Route::get('/roles/create', \App\Livewire\Roles\RoleForm::class)->name('roles.create')
->middleware('permission:roles.create');
Route::get('/roles/{role}/edit', \App\Livewire\Roles\RoleForm::class)->name('roles.edit')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment