Преглед изворни кода

access scopes for flex roles model

Alexander Musikhin пре 1 недеља
родитељ
комит
25a9b65901
59 измењених фајлова са 864 додато и 459 уклоњено
  1. 21 2
      app/Helpers/roles.php
  2. 32 27
      app/Http/Controllers/ChatMessageController.php
  3. 41 29
      app/Http/Controllers/OrderController.php
  4. 2 3
      app/Http/Controllers/ProductSKUController.php
  5. 49 39
      app/Http/Controllers/ReclamationController.php
  6. 10 9
      app/Http/Controllers/ScheduleController.php
  7. 2 2
      app/Http/Controllers/SparePartController.php
  8. 2 2
      app/Http/Controllers/UserController.php
  9. 1 2
      app/Http/Middleware/EnsureRoutePermission.php
  10. 1 1
      app/Http/Middleware/EnsureUserHasRole.php
  11. 1 1
      app/Http/Requests/CreateReclamationRequest.php
  12. 1 1
      app/Http/Requests/ExportScheduleRequest.php
  13. 1 1
      app/Http/Requests/ShipSparePartOrderRequest.php
  14. 1 1
      app/Http/Requests/StoreContractRequest.php
  15. 1 1
      app/Http/Requests/StoreReclamationRequest.php
  16. 1 1
      app/Http/Requests/StoreReclamationSparePartsRequest.php
  17. 1 1
      app/Http/Requests/StoreResponsibleRequest.php
  18. 1 1
      app/Http/Requests/StoreSparePartOrderRequest.php
  19. 1 1
      app/Http/Requests/StoreSparePartRequest.php
  20. 1 1
      app/Http/Requests/UpdateScheduleRequest.php
  21. 1 1
      app/Http/Requests/User/DeleteUser.php
  22. 1 1
      app/Http/Requests/User/StoreUser.php
  23. 55 0
      app/Models/User.php
  24. 1 1
      app/Providers/AppServiceProvider.php
  25. 56 23
      app/Services/Access/AccessService.php
  26. 5 6
      app/Services/NotificationService.php
  27. 13 1
      config/access.php
  28. 5 4
      config/access_routes.php
  29. 21 0
      config/access_scopes.php
  30. 22 0
      database/migrations/2026_06_01_000001_sync_rbac_permissions_for_visibility_scopes.php
  31. 10 2
      database/seeders/RbacSeeder.php
  32. 7 0
      resources/views/admin/roles/partials/permissions-table.blade.php
  33. 3 3
      resources/views/catalog/edit.blade.php
  34. 3 3
      resources/views/catalog/index.blade.php
  35. 1 1
      resources/views/import/index.blade.php
  36. 33 23
      resources/views/layouts/menu.blade.php
  37. 3 3
      resources/views/maf_orders/index.blade.php
  38. 14 14
      resources/views/orders/edit.blade.php
  39. 3 3
      resources/views/orders/index.blade.php
  40. 38 38
      resources/views/orders/show.blade.php
  41. 4 13
      resources/views/partials/chat.blade.php
  42. 2 2
      resources/views/partials/table.blade.php
  43. 9 9
      resources/views/products_sku/edit.blade.php
  44. 2 2
      resources/views/products_sku/index.blade.php
  45. 46 46
      resources/views/reclamations/edit.blade.php
  46. 2 2
      resources/views/reclamations/index.blade.php
  47. 9 9
      resources/views/schedule/index.blade.php
  48. 8 8
      resources/views/spare_part_orders/edit.blade.php
  49. 21 21
      resources/views/spare_parts/edit.blade.php
  50. 7 7
      resources/views/spare_parts/index.blade.php
  51. 48 44
      resources/views/users/edit.blade.php
  52. 29 40
      routes/web.php
  53. 2 0
      tests/Feature/AdminRoleControllerTest.php
  54. 2 0
      tests/Feature/CatalogFieldAccessTest.php
  55. 17 1
      tests/Feature/OrderControllerTest.php
  56. 40 0
      tests/Feature/RoutePermissionMiddlewareTest.php
  57. 54 0
      tests/Feature/ScheduleControllerTest.php
  58. 18 0
      tests/Feature/UserControllerTest.php
  59. 78 2
      tests/Unit/Services/AccessServiceTest.php

+ 21 - 2
app/Helpers/roles.php

@@ -103,8 +103,27 @@ if(!function_exists('hasAccess')) {
         if(!$user) $user = auth()->user();
         if(!$user) return false;
 
-        return $user->hasPermission($permission)
-            || ($legacyRoles !== null && $user->hasRole($legacyRoles));
+        return $user->hasPermission($permission);
+    }
+}
+
+if(!function_exists('visibilityScope')) {
+    function visibilityScope(string $module, $user = null): ?string
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return null;
+
+        return $user->visibilityScope($module);
+    }
+}
+
+if(!function_exists('hasVisibilityScope')) {
+    function hasVisibilityScope(string $module, string $scope, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasVisibilityScope($module, $scope);
     }
 }
 

+ 32 - 27
app/Http/Controllers/ChatMessageController.php

@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
 use App\Models\ChatMessage;
 use App\Models\Order;
 use App\Models\Reclamation;
-use App\Models\Role;
 use App\Models\User;
 use App\Services\FileService;
 use App\Services\NotificationService;
@@ -92,8 +91,11 @@ class ChatMessageController extends Controller
         ?Reclamation $reclamation,
         string $filePath,
     ): RedirectResponse {
-        $isPrivileged = hasRole(Role::ADMIN . ',' . Role::MANAGER);
-        $isBrigadier = hasRole(Role::BRIGADIER);
+        $module = $order ? 'orders' : 'reclamations';
+        $visibilityScope = $request->user()?->visibilityScope($module);
+        $isPrivileged = in_array($visibilityScope, ['admin', 'manager'], true)
+            || (bool) $request->user()?->hasPermission('chat_messages.notify');
+        $isBrigadier = $visibilityScope === 'brigadier';
 
         $validated = $request->validate([
             'message' => 'nullable|string',
@@ -202,7 +204,8 @@ class ChatMessageController extends Controller
         ?Order $order,
         ?Reclamation $reclamation,
     ): void {
-        abort_unless(hasRole(Role::ADMIN), 403);
+        $permission = $order ? 'orders.chat.delete' : 'reclamations.chat.delete';
+        abort_unless(auth()->user()?->hasPermission($permission), 403);
 
         if ($order && (int) $chatMessage->order_id !== (int) $order->id) {
             abort(404);
@@ -238,34 +241,36 @@ class ChatMessageController extends Controller
 
     private function ensureCanViewOrder(Order $order): void
     {
-        if (hasRole(Role::BRIGADIER)) {
-            $canView = (int) $order->brigadier_id === (int) auth()->id()
-                && in_array((int) $order->order_status_id, Order::visibleStatusIdsForBrigadier(), true);
+        $user = auth()->user();
+
+        $canView = match ($user?->visibilityScope('orders')) {
+            'admin', 'manager' => true,
+            'brigadier' => (int) $order->brigadier_id === (int) $user->id
+                && in_array((int) $order->order_status_id, Order::visibleStatusIdsForBrigadier(), true),
+            'warehouse_head' => $order->brigadier_id !== null && $order->installation_date !== null,
+            default => false,
+        };
 
-            if (!$canView) {
-                abort(403);
-            }
+        if (!$canView) {
+            abort(403);
         }
     }
 
     private function ensureCanViewReclamation(Reclamation $reclamation): void
     {
-        if (hasRole(Role::BRIGADIER)) {
-            $canView = (int) $reclamation->brigadier_id === (int) auth()->id()
-                && in_array((int) $reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
-
-            if (!$canView) {
-                abort(403);
-            }
-        }
-
-        if (hasRole(Role::WAREHOUSE_HEAD)) {
-            $canView = $reclamation->brigadier_id !== null
-                && in_array((int) $reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
+        $user = auth()->user();
+
+        $canView = match ($user?->visibilityScope('reclamations')) {
+            'admin', 'manager' => true,
+            'brigadier' => (int) $reclamation->brigadier_id === (int) $user->id
+                && in_array((int) $reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true),
+            'warehouse_head' => $reclamation->brigadier_id !== null
+                && in_array((int) $reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true),
+            default => false,
+        };
 
-            if (!$canView) {
-                abort(403);
-            }
+        if (!$canView) {
+            abort(403);
         }
     }
 
@@ -291,7 +296,7 @@ class ChatMessageController extends Controller
     private function chatResponsibleRecipientIds(?Order $order, ?Reclamation $reclamation): array
     {
         $adminIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission($order ? 'orders.scope.admin' : 'reclamations.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();
@@ -316,7 +321,7 @@ class ChatMessageController extends Controller
     private function chatBrigadierRecipientIds(?Order $order, ?Reclamation $reclamation, int $senderId): array
     {
         $adminIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission($order ? 'orders.scope.admin' : 'reclamations.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();

+ 41 - 29
app/Http/Controllers/OrderController.php

@@ -22,7 +22,6 @@ use App\Models\Order;
 use App\Models\OrderStatus;
 use App\Models\OrderView;
 use App\Models\ProductSKU;
-use App\Models\Role;
 use App\Models\Schedule;
 use App\Models\Ttn;
 use App\Models\User;
@@ -85,8 +84,14 @@ class OrderController extends Controller
         $this->data['areas'] = Area::query()->get()->pluck('name', 'id');
         $this->data['objectTypes'] = ObjectType::query()->get()->pluck('name', 'id');
         $this->data['orderStatuses'] = OrderStatus::query()->orderBy('id')->get()->pluck('name', 'id');
-        $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
-        $this->data['users'] = User::query()->whereIn('role', [Role::MANAGER, Role::ADMIN])->get()->pluck('name', 'id');
+        $this->data['brigadiers'] = User::query()
+            ->withPermission('orders.scope.brigadier')
+            ->get()
+            ->pluck('name', 'id');
+        $this->data['users'] = User::query()
+            ->withAnyPermission(['orders.scope.manager', 'orders.scope.admin'])
+            ->get()
+            ->pluck('name', 'id');
     }
 
 
@@ -109,15 +114,7 @@ class OrderController extends Controller
         $this->acceptSearch($q, $request);
         $this->setSortAndOrderBy($model, $request);
 
-        if(hasRole('brigadier')) {
-            $q->where('brigadier_id', auth()->id())
-                ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier());
-        }
-
-        if(hasRole(Role::WAREHOUSE_HEAD)) {
-            $q->whereNotNull('brigadier_id');
-            $q->whereNotNull('installation_date');
-        }
+        $this->applyOrderVisibilityScope($q, $request->user());
 
         $this->data['filtered_orders_count'] = (clone $q)->count();
         $this->data['filtered_products_total'] = (clone $q)->sum('products_total');
@@ -297,6 +294,33 @@ class OrderController extends Controller
         return redirect()->back()->withInput()->with(['danger' => $errors]);
     }
 
+    private function applyOrderVisibilityScope($query, ?User $user): void
+    {
+        $scope = $user?->visibilityScope('orders');
+
+        match ($scope) {
+            'admin', 'manager' => null,
+            'brigadier' => $query
+                ->where('brigadier_id', $user->id)
+                ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier()),
+            'warehouse_head' => $query
+                ->whereNotNull('brigadier_id')
+                ->whereNotNull('installation_date'),
+            default => $query->whereRaw('1 = 0'),
+        };
+    }
+
+    private function canViewOrderByVisibilityScope(Order $order, ?User $user): bool
+    {
+        return match ($user?->visibilityScope('orders')) {
+            'admin', 'manager' => true,
+            'brigadier' => (int)$order->brigadier_id === (int)$user->id
+                && in_array((int)$order->order_status_id, Order::visibleStatusIdsForBrigadier(), true),
+            'warehouse_head' => $order->brigadier_id !== null && $order->installation_date !== null,
+            default => false,
+        };
+    }
+
     /**
      * Display the specified resource.
      */
@@ -322,13 +346,8 @@ class OrderController extends Controller
             }
         }
 
-        if (hasRole('brigadier') && $this->data['order']) {
-            $canView = (int)$this->data['order']->brigadier_id === (int)auth()->id()
-                && in_array((int)$this->data['order']->order_status_id, Order::visibleStatusIdsForBrigadier(), true);
-
-            if (!$canView) {
-                abort(403);
-            }
+        if ($this->data['order'] && !$this->canViewOrderByVisibilityScope($this->data['order'], $request->user())) {
+            abort(403);
         }
 
         $nav = $this->resolveNavToken($request);
@@ -342,7 +361,7 @@ class OrderController extends Controller
         $orderModel = $this->data['order'];
         $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
         $responsibleUserIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission('orders.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();
@@ -843,15 +862,7 @@ class OrderController extends Controller
             $this->acceptSearch($q, $filterRequest);
             $this->setSortAndOrderBy($model, $filterRequest);
 
-            if (hasRole('brigadier')) {
-                $q->where('brigadier_id', auth()->id())
-                    ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier());
-            }
-
-            if (hasRole(Role::WAREHOUSE_HEAD)) {
-                $q->whereNotNull('brigadier_id');
-                $q->whereNotNull('installation_date');
-            }
+            $this->applyOrderVisibilityScope($q, $request->user());
 
             $this->applyStableSorting($q);
             $orderIds = $q->pluck('id');
@@ -862,6 +873,7 @@ class OrderController extends Controller
                 ->sortBy(static fn (Order $order) => $orderIds->search($order->id))
                 ->values();
         } else {
+            $this->applyOrderVisibilityScope($orders, $request->user());
             $orders = $orders->get();
         }
 

+ 2 - 3
app/Http/Controllers/ProductSKUController.php

@@ -9,7 +9,6 @@ use App\Jobs\ExportMafRegistryJob;
 use App\Models\File;
 use App\Models\MafView;
 use App\Models\ProductSKU;
-use App\Models\Role;
 use App\Services\FileService;
 use App\Services\OrderPaymentStatusService;
 use Illuminate\Http\JsonResponse;
@@ -81,7 +80,7 @@ class ProductSKUController extends Controller
 //        dump($q->toRawSql());
         $this->data['products_sku'] = $q->paginate($this->data['per_page'])->withQueryString();
         $this->data['nav'] = $nav;
-        $this->data['maf_registry_files'] = $request->user()?->hasRole([Role::ADMIN, Role::ASSISTANT_HEAD])
+        $this->data['maf_registry_files'] = $request->user()?->hasPermission('maf.registry.export')
             ? File::query()
                 ->with('user:id,name')
                 ->where('path', 'like', 'export/maf-registry/%')
@@ -205,7 +204,7 @@ class ProductSKUController extends Controller
 
     public function exportMafRegistry(Request $request)
     {
-        abort_unless($request->user()?->hasRole([Role::ADMIN, Role::ASSISTANT_HEAD]), 403);
+        abort_unless($request->user()?->hasPermission('maf.registry.export'), 403);
 
         $validated = $request->validate([
             'upd_number' => 'required|string|max:255',

+ 49 - 39
app/Http/Controllers/ReclamationController.php

@@ -16,7 +16,6 @@ use App\Models\Reclamation;
 use App\Models\ReclamationDetail;
 use App\Models\ReclamationStatus;
 use App\Models\ReclamationView;
-use App\Models\Role;
 use App\Models\User;
 use App\Services\FileService;
 use App\Services\NotificationService;
@@ -61,7 +60,10 @@ class ReclamationController extends Controller
 
     public function __construct()
     {
-        $this->data['users'] = User::query()->whereIn('role', [Role::MANAGER, Role::ADMIN])->get()->pluck('name', 'id');
+        $this->data['users'] = User::query()
+            ->withAnyPermission(['reclamations.scope.manager', 'reclamations.scope.admin'])
+            ->get()
+            ->pluck('name', 'id');
         $this->data['statuses'] = ReclamationStatus::query()->get()->pluck('name', 'id');
     }
 
@@ -80,13 +82,7 @@ class ReclamationController extends Controller
         $this->acceptSearch($q, $request);
         $this->setSortAndOrderBy($model, $request);
 
-        if (hasRole(Role::BRIGADIER)) {
-            $q->where('brigadier_id', auth()->id())
-                ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier());
-        } elseif (hasRole(Role::WAREHOUSE_HEAD)) {
-            $q->whereNotNull('brigadier_id')
-                ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier());
-        }
+        $this->applyReclamationVisibilityScope($q, $request->user());
 
         $this->applyStableSorting($q);
         $this->data['reclamations'] = $q->paginate($this->data['per_page'])->withQueryString();
@@ -118,6 +114,7 @@ class ReclamationController extends Controller
         $this->acceptFilters($q, $filterRequest);
         $this->acceptSearch($q, $filterRequest);
         $this->setSortAndOrderBy($model, $filterRequest);
+        $this->applyReclamationVisibilityScope($q, $request->user());
         $this->applyStableSorting($q);
 
         $reclamationIds = $q->pluck('id')->toArray();
@@ -148,7 +145,10 @@ class ReclamationController extends Controller
     {
         $this->ensureCanViewReclamation($reclamation);
 
-        $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
+        $this->data['brigadiers'] = User::query()
+            ->withPermission('reclamations.scope.brigadier')
+            ->get()
+            ->pluck('name', 'id');
         $this->data['reclamation'] = $reclamation->load([
             'order',
             'chatMessages.user',
@@ -158,7 +158,7 @@ class ReclamationController extends Controller
         ]);
         $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
         $responsibleUserIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission('reclamations.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();
@@ -207,9 +207,7 @@ class ReclamationController extends Controller
 
     public function updateStatus(Request $request, Reclamation $reclamation, NotificationService $notificationService)
     {
-        if (!hasRole('admin,manager')) {
-            abort(403);
-        }
+        $this->ensureHasPermission('reclamations.status.update');
 
         $validated = $request->validate([
             'status_id' => 'required|exists:reclamation_statuses,id',
@@ -229,7 +227,7 @@ class ReclamationController extends Controller
 
     public function uploadPhotoBefore(Request $request, Reclamation $reclamation, FileService $fileService)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.photos.upload');
         $this->ensureCanViewReclamation($reclamation);
 
         $data = $request->validate([
@@ -278,7 +276,7 @@ class ReclamationController extends Controller
 
     public function deletePhotoBefore(Request $request, Reclamation $reclamation, File $file, FileService $fileService)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.photos.delete');
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->photos_before()->detach($file);
@@ -289,7 +287,7 @@ class ReclamationController extends Controller
 
     public function deletePhotoAfter(Request $request, Reclamation $reclamation, File $file, FileService $fileService)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.photos.delete');
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->photos_after()->detach($file);
@@ -300,7 +298,7 @@ class ReclamationController extends Controller
 
     public function uploadDocument(Request $request, Reclamation $reclamation, FileService $fileService)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.documents.upload');
         $this->ensureCanViewReclamation($reclamation);
 
         $data = $request->validate([
@@ -327,7 +325,7 @@ class ReclamationController extends Controller
 
     public function deleteDocument(Request $request, Reclamation $reclamation, File $file)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.documents.delete');
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->documents()->detach($file);
@@ -338,7 +336,7 @@ class ReclamationController extends Controller
 
     public function uploadAct(Request $request, Reclamation $reclamation, FileService $fileService)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER, Role::BRIGADIER, Role::WAREHOUSE_HEAD]);
+        $this->ensureHasPermission('reclamations.act.upload');
         $this->ensureCanViewReclamation($reclamation);
 
         $data = $request->validate([
@@ -365,7 +363,7 @@ class ReclamationController extends Controller
 
     public function deleteAct(Request $request, Reclamation $reclamation, File $file)
     {
-        $this->ensureHasRole([Role::ADMIN, Role::MANAGER]);
+        $this->ensureHasPermission('reclamations.act.delete');
         $this->ensureCanViewReclamation($reclamation);
 
         $reclamation->acts()->detach($file);
@@ -590,30 +588,42 @@ class ReclamationController extends Controller
 
     private function ensureCanViewReclamation(Reclamation $reclamation): void
     {
-        if (hasRole(Role::BRIGADIER)) {
-            $canView = (int)$reclamation->brigadier_id === (int)auth()->id()
-                && in_array((int)$reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
-
-            if (!$canView) {
-                abort(403);
-            }
+        if (!$this->canViewReclamationByVisibilityScope($reclamation, auth()->user())) {
+            abort(403);
         }
+    }
 
-        if (hasRole(Role::WAREHOUSE_HEAD)) {
-            $canView = $reclamation->brigadier_id !== null
-                && in_array((int)$reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
+    private function applyReclamationVisibilityScope($query, ?User $user): void
+    {
+        $scope = $user?->visibilityScope('reclamations');
+
+        match ($scope) {
+            'admin', 'manager' => null,
+            'brigadier' => $query
+                ->where('brigadier_id', $user->id)
+                ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier()),
+            'warehouse_head' => $query
+                ->whereNotNull('brigadier_id')
+                ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier()),
+            default => $query->whereRaw('1 = 0'),
+        };
+    }
 
-            if (!$canView) {
-                abort(403);
-            }
-        }
+    private function canViewReclamationByVisibilityScope(Reclamation $reclamation, ?User $user): bool
+    {
+        return match ($user?->visibilityScope('reclamations')) {
+            'admin', 'manager' => true,
+            'brigadier' => (int)$reclamation->brigadier_id === (int)$user->id
+                && in_array((int)$reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true),
+            'warehouse_head' => $reclamation->brigadier_id !== null
+                && in_array((int)$reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true),
+            default => false,
+        };
     }
 
-    private function ensureHasRole(array $roles): void
+    private function ensureHasPermission(string $permission): void
     {
-        if (!count(array_intersect($roles, Role::effectiveRoles((string)auth()->user()?->role)))) {
-            abort(403);
-        }
+        abort_unless(auth()->user()?->hasPermission($permission), 403);
     }
 
     private function redirectToReclamationShow(Request $request, Reclamation $reclamation)

+ 10 - 9
app/Http/Controllers/ScheduleController.php

@@ -14,7 +14,6 @@ use App\Models\Order;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
 use App\Models\ReclamationStatus;
-use App\Models\Role;
 use App\Models\Schedule;
 use App\Models\User;
 use App\Services\NotificationService;
@@ -35,9 +34,10 @@ class ScheduleController extends Controller
     {
         $this->data['districts'] = District::query()->get()->pluck('name', 'id');
         $this->data['areas'] = Area::query()->get()->pluck('name', 'id');
+        $scheduleScope = $request->user()?->visibilityScope('schedule');
         $this->data['brigadiers'] = User::query()
-            ->where('role', Role::BRIGADIER)
-            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->whereKey(auth()->id()))
+            ->withPermission('schedule.scope.brigadier')
+            ->when($scheduleScope === 'brigadier', fn (Builder $query) => $query->whereKey($request->user()->id))
             ->get()
             ->pluck('name', 'id');
 
@@ -68,7 +68,7 @@ class ScheduleController extends Controller
         }
         $result = Schedule::query()
             ->whereBetween('installation_date', [$weekDates['mon'], $weekDates['sun']])
-            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->where('brigadier_id', auth()->id()))
+            ->when($scheduleScope === 'brigadier', fn (Builder $query) => $query->where('brigadier_id', $request->user()->id))
             ->with(['brigadier', 'district', 'area'])
             ->get();
         $result = $this->filterSchedulesForCurrentUser($result);
@@ -112,7 +112,7 @@ class ScheduleController extends Controller
 
         $monthSchedules = Schedule::query()
             ->whereBetween('installation_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
-            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->where('brigadier_id', auth()->id()))
+            ->when($scheduleScope === 'brigadier', fn (Builder $query) => $query->where('brigadier_id', $request->user()->id))
             ->with(['brigadier'])
             ->get();
         $monthSchedules = $this->filterSchedulesForCurrentUser($monthSchedules);
@@ -392,12 +392,13 @@ class ScheduleController extends Controller
 
     private function filterSchedulesForCurrentUser(Collection $schedules): Collection
     {
-        if (!hasRole(Role::BRIGADIER)) {
+        $user = auth()->user();
+        if ($user?->visibilityScope('schedule') !== 'brigadier') {
             return $schedules;
         }
 
         $brigadierSchedules = $schedules
-            ->where('brigadier_id', auth()->id())
+            ->where('brigadier_id', $user->id)
             ->values();
 
         if ($brigadierSchedules->isEmpty()) {
@@ -407,7 +408,7 @@ class ScheduleController extends Controller
         $visibleOrderIds = Order::query()
             ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
             ->whereIn('id', $brigadierSchedules->pluck('order_id')->filter()->unique()->all())
-            ->where('brigadier_id', auth()->id())
+            ->where('brigadier_id', $user->id)
             ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier())
             ->pluck('id')
             ->all();
@@ -422,7 +423,7 @@ class ScheduleController extends Controller
         $visibleReclamationIds = Reclamation::query()
             ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
             ->whereIn('id', $reclamationIds->all())
-            ->where('brigadier_id', auth()->id())
+            ->where('brigadier_id', $user->id)
             ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier())
             ->pluck('id')
             ->all();

+ 2 - 2
app/Http/Controllers/SparePartController.php

@@ -55,7 +55,7 @@ class SparePartController extends Controller
         $model = new SparePartsView();
 
         // Для админа добавляем колонку цены закупки
-        if (hasRole('admin')) {
+        if ($request->user()?->hasPermission('spare_parts.purchase_price.view')) {
             $this->data['header'] = array_merge(
                 array_slice($this->data['header'], 0, 8, true),
                 ['purchase_price_txt' => 'Цена закупки'],
@@ -77,7 +77,7 @@ class SparePartController extends Controller
             'title' => $this->data['header']['pricing_codes_list'],
             'values' => [],
         ];
-        if (hasRole('admin')) {
+        if ($request->user()?->hasPermission('spare_parts.purchase_price.view')) {
             $this->data['filters']['purchase_price_txt'] = [
                 'title' => $this->data['header']['purchase_price_txt'],
                 'values' => [],

+ 2 - 2
app/Http/Controllers/UserController.php

@@ -269,7 +269,7 @@ class UserController extends Controller
             return redirect()->route('login')->with(['danger' => 'Не удалось вернуться к исходному пользователю.']);
         }
 
-        abort_unless($impersonator->resolvedRoleSlug() === Role::ADMIN, 403);
+        abort_unless($impersonator->hasPermission('users.impersonate'), 403);
 
         Auth::login($impersonator);
         $request->session()->forget('impersonator_id');
@@ -338,7 +338,7 @@ class UserController extends Controller
 
     private function syncUserPermissionOverrides(User $user, array $effects): void
     {
-        if ($user->resolvedRoleSlug() === Role::ADMIN) {
+        if ($user->roleModel?->is_system && $user->roleModel?->slug === Role::ADMIN) {
             $user->permissions()->detach();
             return;
         }

+ 1 - 2
app/Http/Middleware/EnsureRoutePermission.php

@@ -24,8 +24,7 @@ class EnsureRoutePermission
             return $next($request);
         }
 
-        // Compatibility while tests and old runtime paths still create users with only legacy role slugs.
-        if (!$user->role_id) {
+        if ($routePermission === true) {
             return $next($request);
         }
 

+ 1 - 1
app/Http/Middleware/EnsureUserHasRole.php

@@ -28,7 +28,7 @@ class EnsureUserHasRole
         $routeName = $request->route()?->getName();
         $hasRoutePermission = $user && $this->accessService->canAccessRoute($user, $routeName);
 
-        if ($user?->hasRole($roles) || $hasRoutePermission) {
+        if ($hasRoutePermission) {
             return $next($request);
         }
 

+ 1 - 1
app/Http/Requests/CreateReclamationRequest.php

@@ -11,7 +11,7 @@ class CreateReclamationRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin,manager');
+        return $this->user()?->hasPermission('reclamations.create') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/ExportScheduleRequest.php

@@ -11,7 +11,7 @@ class ExportScheduleRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasPermission('schedule.export') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/ShipSparePartOrderRequest.php

@@ -11,7 +11,7 @@ class ShipSparePartOrderRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin,manager');
+        return $this->user()?->hasPermission('spare_part_orders.ship') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreContractRequest.php

@@ -11,7 +11,7 @@ class StoreContractRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasAnyPermission(['contracts.create', 'contracts.update']) === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreReclamationRequest.php

@@ -11,7 +11,7 @@ class StoreReclamationRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin,manager');
+        return $this->user()?->hasPermission('reclamations.update') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreReclamationSparePartsRequest.php

@@ -11,7 +11,7 @@ class StoreReclamationSparePartsRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin,manager');
+        return $this->user()?->hasPermission('reclamations.spare_parts.manage') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreResponsibleRequest.php

@@ -11,7 +11,7 @@ class StoreResponsibleRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return auth()->check() && hasRole('admin,manager');
+        return $this->user()?->hasAnyPermission(['responsibles.create', 'responsibles.update']) === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreSparePartOrderRequest.php

@@ -11,7 +11,7 @@ class StoreSparePartOrderRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin,manager');
+        return $this->user()?->hasAnyPermission(['spare_part_orders.create', 'spare_part_orders.update']) === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/StoreSparePartRequest.php

@@ -11,7 +11,7 @@ class StoreSparePartRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasAnyPermission(['spare_parts.create', 'spare_parts.update']) === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/UpdateScheduleRequest.php

@@ -11,7 +11,7 @@ class UpdateScheduleRequest extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasPermission('schedule.update') === true;
     }
 
     /**

+ 1 - 1
app/Http/Requests/User/DeleteUser.php

@@ -20,6 +20,6 @@ class DeleteUser extends FormRequest
 
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasPermission('users.delete') === true;
     }
 }

+ 1 - 1
app/Http/Requests/User/StoreUser.php

@@ -14,7 +14,7 @@ class StoreUser extends FormRequest
      */
     public function authorize(): bool
     {
-        return hasRole('admin');
+        return $this->user()?->hasAnyPermission(['users.create', 'users.update']) === true;
     }
 
     /**

+ 55 - 0
app/Models/User.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 
 use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -95,6 +96,50 @@ class User extends Authenticatable implements MustVerifyEmail
             ->withTimestamps();
     }
 
+    public function scopeWithPermission(Builder $query, string $permission): Builder
+    {
+        return $query->where(function (Builder $query) use ($permission): void {
+            $query
+                ->whereHas('roleModel.permissions', function (Builder $query) use ($permission): void {
+                    $query
+                        ->where('slug', $permission)
+                        ->where('role_permissions.effect', 'allow');
+                })
+                ->orWhereExists(function ($query) use ($permission): void {
+                    $query
+                        ->selectRaw('1')
+                        ->from('roles')
+                        ->join('role_permissions', 'role_permissions.role_id', '=', 'roles.id')
+                        ->join('permissions', 'permissions.id', '=', 'role_permissions.permission_id')
+                        ->whereColumn('roles.slug', 'users.role')
+                        ->where('permissions.slug', $permission)
+                        ->where('role_permissions.effect', 'allow');
+                })
+                ->orWhereHas('permissions', function (Builder $query) use ($permission): void {
+                    $query
+                        ->where('slug', $permission)
+                        ->where('user_permissions.effect', 'allow')
+                        ->where(function (Builder $query): void {
+                            $query
+                                ->whereNull('user_permissions.expires_at')
+                                ->orWhere('user_permissions.expires_at', '>', now());
+                        });
+                });
+        });
+    }
+
+    public function scopeWithAnyPermission(Builder $query, array|string $permissions): Builder
+    {
+        $permissions = is_array($permissions) ? $permissions : explode(',', $permissions);
+        $permissions = array_filter(array_map('trim', $permissions));
+
+        return $query->where(function (Builder $query) use ($permissions): void {
+            foreach ($permissions as $permission) {
+                $query->orWhere(fn (Builder $query): Builder => $query->withPermission($permission));
+            }
+        });
+    }
+
     public function hasRole(string|array $roles): bool
     {
         $roles = is_array($roles) ? $roles : explode(',', $roles);
@@ -120,6 +165,16 @@ class User extends Authenticatable implements MustVerifyEmail
         return app(\App\Services\Access\AccessService::class)->canAny($this, $permissions);
     }
 
+    public function visibilityScope(string $module): ?string
+    {
+        return app(\App\Services\Access\AccessService::class)->visibilityScope($this, $module);
+    }
+
+    public function hasVisibilityScope(string $module, string $scope): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->hasVisibilityScope($this, $module, $scope);
+    }
+
     public function canViewField(string $module, string $field, ?string $entity = null): bool
     {
         return app(\App\Services\Access\AccessService::class)->canViewField($this, $module, $field, $entity);

+ 1 - 1
app/Providers/AppServiceProvider.php

@@ -31,9 +31,9 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme('https');
         }
 
-        Blade::if('role', fn($roles) => hasRole($roles));
         Blade::if('permission', fn($permission) => hasPermission($permission));
         Blade::if('anypermission', fn($permissions) => hasAnyPermission($permissions));
+        Blade::if('scope', fn($module, $scope) => hasVisibilityScope($module, $scope));
         Blade::if('fieldView', fn($module, $field, $entity = null) => canViewField($module, $field, $entity));
         Blade::if('fieldUpdate', fn($module, $field, $entity = null) => canUpdateField($module, $field, $entity));
 

+ 56 - 23
app/Services/Access/AccessService.php

@@ -16,10 +16,6 @@ class AccessService
 
     public function can(User $user, string $permission): bool
     {
-        if ($this->isDirectAdmin($user)) {
-            return true;
-        }
-
         $effect = $this->getEffectivePermissions($user)->get($permission);
 
         return $effect === 'allow';
@@ -36,6 +32,35 @@ class AccessService
         return false;
     }
 
+    public function visibilityScope(User $user, string $module): ?string
+    {
+        $scopes = config("access_scopes.{$module}", []);
+        if ($scopes === []) {
+            return null;
+        }
+
+        $bestScope = null;
+        $bestPriority = PHP_INT_MIN;
+
+        foreach ($scopes as $scope => $priority) {
+            if (!$this->can($user, "{$module}.scope.{$scope}")) {
+                continue;
+            }
+
+            if ((int) $priority > $bestPriority) {
+                $bestScope = (string) $scope;
+                $bestPriority = (int) $priority;
+            }
+        }
+
+        return $bestScope;
+    }
+
+    public function hasVisibilityScope(User $user, string $module, string $scope): bool
+    {
+        return $this->visibilityScope($user, $module) === $scope;
+    }
+
     public function canViewField(User $user, string $module, string $field, ?string $entity = null): bool
     {
         return $this->can($user, $this->fieldPermissionSlug($module, $field, 'view'));
@@ -68,7 +93,7 @@ class AccessService
         abort_unless($this->can($user, $permission), 403);
     }
 
-    public function routePermission(?string $routeName): array|string|null
+    public function routePermission(?string $routeName): array|string|bool|null
     {
         if (!$routeName) {
             return null;
@@ -96,6 +121,10 @@ class AccessService
     {
         $permission = $this->routePermission($routeName);
 
+        if ($permission === true) {
+            return true;
+        }
+
         if ($permission === null) {
             return false;
         }
@@ -107,10 +136,6 @@ class AccessService
 
     public function roleHasPermission(Role $role, string $permission): bool
     {
-        if ($role->slug === Role::ADMIN) {
-            return true;
-        }
-
         $permissionModel = $role->permissions()
             ->where('slug', $permission)
             ->first();
@@ -120,14 +145,6 @@ class AccessService
 
     public function getEffectivePermissions(User $user): Collection
     {
-        if ($this->isDirectAdmin($user)) {
-            try {
-                return Permission::query()->pluck('slug')->mapWithKeys(fn (string $slug): array => [$slug => 'allow']);
-            } catch (QueryException) {
-                return collect();
-            }
-        }
-
         return Cache::remember(
             $this->cacheKey($user),
             now()->addHour(),
@@ -150,6 +167,13 @@ class AccessService
         $denied = collect();
 
         $role = $user->roleModel()->with('permissions')->first();
+        if (!$role && $user->resolvedRoleSlug()) {
+            $role = Role::query()
+                ->where('slug', $user->resolvedRoleSlug())
+                ->with('permissions')
+                ->first();
+        }
+
         if ($role) {
             foreach ($role->permissions as $permission) {
                 $effect = $permission->pivot->effect;
@@ -188,11 +212,6 @@ class AccessService
         return $effects;
     }
 
-    private function isDirectAdmin(User $user): bool
-    {
-        return $user->resolvedRoleSlug() === Role::ADMIN;
-    }
-
     private function fieldPermissionSlug(string $module, string $field, string $action): string
     {
         return "{$module}.fields.{$field}.{$action}";
@@ -200,7 +219,21 @@ class AccessService
 
     private function cacheKey(User $user): string
     {
-        return 'permissions:user:' . $user->id . ':v' . $this->cacheVersion();
+        $role = $user->relationLoaded('roleModel')
+            ? $user->roleModel
+            : $user->roleModel()->first();
+
+        return implode(':', [
+            'permissions',
+            'user',
+            $user->id,
+            sha1((string) $user->email),
+            $user->getAttribute('role_id') ?: 'legacy',
+            $user->resolvedRoleSlug() ?: 'none',
+            optional($user->updated_at)->timestamp ?: 0,
+            optional($role?->updated_at)->timestamp ?: 0,
+            'v' . $this->cacheVersion(),
+        ]);
     }
 
     private function cacheVersion(): int

+ 5 - 6
app/Services/NotificationService.php

@@ -9,7 +9,6 @@ use App\Models\ChatMessage;
 use App\Models\NotificationDeliveryLog;
 use App\Models\Order;
 use App\Models\Reclamation;
-use App\Models\Role;
 use App\Models\Schedule;
 use App\Models\User;
 use App\Models\UserNotification;
@@ -491,7 +490,7 @@ class NotificationService
     private function orderRecipients(Order $order): Collection
     {
         $query = User::query()
-            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+            ->withAnyPermission(['orders.scope.admin', 'orders.scope.warehouse_head']);
 
         if ($order->user_id) {
             $query->orWhere('id', $order->user_id);
@@ -503,7 +502,7 @@ class NotificationService
     private function reclamationRecipients(Reclamation $reclamation): Collection
     {
         $query = User::query()
-            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+            ->withAnyPermission(['reclamations.scope.admin', 'reclamations.scope.warehouse_head']);
 
         $managerId = $reclamation->user_id;
         if ($managerId) {
@@ -516,7 +515,7 @@ class NotificationService
     private function scheduleRecipients(Schedule $schedule): Collection
     {
         $query = User::query()
-            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+            ->withAnyPermission(['schedule.scope.admin', 'orders.scope.warehouse_head', 'reclamations.scope.warehouse_head']);
 
         if ($schedule->brigadier_id) {
             $query->orWhere('id', $schedule->brigadier_id);
@@ -631,7 +630,7 @@ class NotificationService
     private function chatResponsibleRecipientIdsForOrder(Order $order): array
     {
         $adminIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission('orders.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();
@@ -645,7 +644,7 @@ class NotificationService
     private function chatResponsibleRecipientIdsForReclamation(Reclamation $reclamation): array
     {
         $adminIds = User::query()
-            ->where('role', Role::ADMIN)
+            ->withPermission('reclamations.scope.admin')
             ->pluck('id')
             ->map(static fn ($id) => (int) $id)
             ->all();

+ 13 - 1
config/access.php

@@ -20,6 +20,10 @@ return [
             'contractor_specification.create' => 'Спецификация подрядчика',
             'chat.create' => 'Сообщения',
             'chat.delete' => 'Удаление сообщений',
+            'scope.admin' => 'Область видимости: администратор',
+            'scope.manager' => 'Область видимости: менеджер',
+            'scope.warehouse_head' => 'Область видимости: руководитель склада',
+            'scope.brigadier' => 'Область видимости: бригадир',
         ],
     ],
     'reclamations' => [
@@ -42,6 +46,10 @@ return [
             'spare_parts.manage' => 'Управление запчастями',
             'chat.create' => 'Сообщения',
             'chat.delete' => 'Удаление сообщений',
+            'scope.admin' => 'Область видимости: администратор',
+            'scope.manager' => 'Область видимости: менеджер',
+            'scope.warehouse_head' => 'Область видимости: руководитель склада',
+            'scope.brigadier' => 'Область видимости: бригадир',
         ],
     ],
     'schedule' => [
@@ -53,6 +61,9 @@ return [
             'update' => 'Редактирование',
             'delete' => 'Удаление',
             'export' => 'Экспорт',
+            'scope.admin' => 'Область видимости: администратор',
+            'scope.manager' => 'Область видимости: менеджер',
+            'scope.brigadier' => 'Область видимости: бригадир',
         ],
     ],
     'catalog' => [
@@ -110,6 +121,7 @@ return [
             'export' => 'Экспорт',
             'passports.upload' => 'Загрузка паспорта',
             'passports.delete' => 'Удаление паспорта',
+            'registry.export' => 'Экспорт реестра МАФ',
         ],
         'fields' => [
             'rfid' => 'RFID',
@@ -209,7 +221,7 @@ return [
     'spare_parts' => [
         'name' => 'Запчасти',
         'entity' => 'spare_part',
-        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'import' => 'Импорт', 'export' => 'Экспорт', 'image.upload' => 'Загрузка изображения', 'search' => 'Поиск'],
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'import' => 'Импорт', 'export' => 'Экспорт', 'image.upload' => 'Загрузка изображения', 'search' => 'Поиск', 'purchase_price.view' => 'Просмотр цены закупки'],
     ],
     'spare_part_orders' => [
         'name' => 'Заказы запчастей',

+ 5 - 4
config/access_routes.php

@@ -4,10 +4,10 @@ return [
     'exact' => [
         'area.ajax-get-areas-by-district' => 'areas.ajax.view',
         'getFilters' => 'filters.view',
-        'notifications.index' => 'notifications.view',
-        'notifications.read-all' => 'notifications.mark_read',
-        'notifications.read' => 'notifications.mark_read',
-        'notifications.unread-count' => 'notifications.view',
+        'notifications.index' => true,
+        'notifications.read-all' => true,
+        'notifications.read' => true,
+        'notifications.unread-count' => true,
         'product.search' => 'catalog.search',
         'pricing_codes.get_description' => 'pricing_codes.search',
         'pricing_codes.search' => 'pricing_codes.search',
@@ -97,6 +97,7 @@ return [
         'mafs.' => [
             'import' => 'maf.import',
             'export' => 'maf.export',
+            'registry' => 'maf.registry.export',
         ],
         'order.' => [
             'index' => 'orders.view',

+ 21 - 0
config/access_scopes.php

@@ -0,0 +1,21 @@
+<?php
+
+return [
+    'orders' => [
+        'admin' => 100,
+        'manager' => 80,
+        'warehouse_head' => 60,
+        'brigadier' => 40,
+    ],
+    'reclamations' => [
+        'admin' => 100,
+        'manager' => 80,
+        'warehouse_head' => 60,
+        'brigadier' => 40,
+    ],
+    'schedule' => [
+        'admin' => 100,
+        'manager' => 80,
+        'brigadier' => 40,
+    ],
+];

+ 22 - 0
database/migrations/2026_06_01_000001_sync_rbac_permissions_for_visibility_scopes.php

@@ -0,0 +1,22 @@
+<?php
+
+use Database\Seeders\RbacSeeder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (
+            !Schema::hasTable('roles')
+            || !Schema::hasTable('permissions')
+            || !Schema::hasTable('role_permissions')
+            || !Schema::hasTable('user_permissions')
+        ) {
+            return;
+        }
+
+        app(RbacSeeder::class)->run();
+    }
+};

+ 10 - 2
database/seeders/RbacSeeder.php

@@ -126,6 +126,7 @@ class RbacSeeder extends Seeder
 
         $manager = array_merge($auth, [
             'orders.update',
+            'orders.create',
             'orders.export',
             'orders.documents.upload',
             'orders.documents.delete',
@@ -147,8 +148,6 @@ class RbacSeeder extends Seeder
             'maf.update',
             'maf.passports.upload',
             'contracts.view',
-            'contracts.create',
-            'contracts.update',
             'contracts.delete',
             'responsibles.view',
             'responsibles.create',
@@ -170,16 +169,25 @@ class RbacSeeder extends Seeder
             'spare_part_inventory.view',
             'chat_messages.create',
             'chat_messages.notify',
+            'orders.scope.manager',
+            'reclamations.scope.manager',
+            'schedule.scope.manager',
         ]);
 
         $manager = array_merge($manager, $this->fieldPermissions('catalog', ['view'], $permissions));
 
         $brigadier = array_merge($auth, [
             'reclamations.act.upload',
+            'orders.scope.brigadier',
+            'reclamations.scope.brigadier',
+            'schedule.scope.brigadier',
         ]);
 
         $warehouseHead = array_merge($auth, [
             'reclamations.act.upload',
+            'orders.scope.warehouse_head',
+            'reclamations.scope.warehouse_head',
+            'schedule.scope.manager',
         ]);
 
         $rolePermissions = [

+ 7 - 0
resources/views/admin/roles/partials/permissions-table.blade.php

@@ -1,5 +1,6 @@
 @php
     $effects = old($inputName, $permissionEffects ?? []);
+    $inheritedEffects = $inheritedPermissionEffects ?? [];
     $adminLocked = $adminLocked ?? false;
     $inheritLabel = $inheritLabel ?? 'Наследовать';
 @endphp
@@ -33,12 +34,18 @@
                             <tbody>
                             @foreach($permissions as $permission)
                                 @php($value = $adminLocked ? 'allow' : ($effects[$permission->id] ?? 'none'))
+                                @php($inheritedValue = $inheritedEffects[$permission->id] ?? null)
                                 <tr>
                                     <td>
                                         {{ $permission->name }}
                                         @if($permission->type === \App\Models\Permission::TYPE_FIELD)
                                             <span class="badge text-bg-light">поле</span>
                                         @endif
+                                        @if($inheritedValue)
+                                            <span class="badge text-bg-{{ $inheritedValue === 'allow' ? 'success' : 'danger' }} ms-1">
+                                                По роли: {{ $inheritedValue === 'allow' ? 'разрешено' : 'запрещено' }}
+                                            </span>
+                                        @endif
                                     </td>
                                     <td><code>{{ $permission->slug }}</code></td>
                                     <td class="text-end" style="min-width: 180px;">

+ 3 - 3
resources/views/catalog/edit.blade.php

@@ -20,7 +20,7 @@
                             <img src="{{ $product->image }}" alt="Миниатюра" class="img-thumbnail img-max-40">
                         </a>
                     @endif
-                    @if(hasAccess('catalog.thumbnail.upload', 'admin'))
+                    @if(hasAccess('catalog.thumbnail.upload'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-thumb').trigger('click');" title="Загрузить изображение"><i class="bi bi-image"></i> Изображение</button>
 
                         <form action="{{ route('catalog.upload-thumbnail', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
@@ -29,7 +29,7 @@
                         </form>
                     @endif
 
-                    @if(hasAccess('catalog.certificates.upload', 'admin'))
+                    @if(hasAccess('catalog.certificates.upload'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-cert').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить сертификат</button>
 
                         <form action="{{ route('catalog.upload-certificate', ['product' => $product, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
@@ -94,7 +94,7 @@
                         @endif
                     </div>
                     <div class="col-12">
-                        @include('partials.submit', ['deleteDisabled' => (!isset($product) || $product->hasRelations() || !hasAccess('catalog.delete', 'admin')), 'disabled' => !(($product && hasAccess('catalog.update', 'admin')) || (!$product && hasAccess('catalog.create', 'admin'))), 'offset' => 6, 'delete' => ['form_id' => 'deleteProduct'], 'back_url' => $back_url ?? route('catalog.index', session('gp_products'))])
+                        @include('partials.submit', ['deleteDisabled' => (!isset($product) || $product->hasRelations() || !hasAccess('catalog.delete')), 'disabled' => !(($product && hasAccess('catalog.update')) || (!$product && hasAccess('catalog.create'))), 'offset' => 6, 'delete' => ['form_id' => 'deleteProduct'], 'back_url' => $back_url ?? route('catalog.index', session('gp_products'))])
                     </div>
                 </div>
 

+ 3 - 3
resources/views/catalog/index.blade.php

@@ -10,19 +10,19 @@
             @include('partials.year-switcher')
         </div>
         <div class="col-auto col-md-4 text-md-end page-header-actions">
-            @if(hasAccess('catalog.import', 'admin'))
+            @if(hasAccess('catalog.import'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#importModal" aria-label="Импорт">
                     <i class="bi bi-upload page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Импорт</span>
                 </button>
             @endif
-            @if(hasAccess('catalog.export', 'admin'))
+            @if(hasAccess('catalog.export'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#exportModal" aria-label="Экспорт">
                     <i class="bi bi-download page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Экспорт</span>
                 </button>
             @endif
-            @if(hasAccess('catalog.create', 'admin'))
+            @if(hasAccess('catalog.create'))
                 <a href="{{ route('catalog.create') }}" class="btn btn-sm mb-1 btn-primary page-action-btn" aria-label="Добавить">
                     <i class="bi bi-plus-lg page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Добавить</span>

+ 1 - 1
resources/views/import/index.blade.php

@@ -5,7 +5,7 @@
         <div class="col-md-6">
             <h3>Импорт</h3>
         </div>
-        @if(hasRole('admin'))
+        @if(hasPermission('import.create'))
             <div class="col-md-6 text-end">
                 <button type="button" class="btn btn-sm mb-1 btn-primary" data-bs-toggle="modal" data-bs-target="#importModal">
                     Импорт

+ 33 - 23
resources/views/layouts/menu.blade.php

@@ -1,52 +1,62 @@
 @if(auth()->check())
-    @if(hasAccess('orders.view', \App\Models\Role::VALID_ROLES))
+    @if(hasPermission('orders.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'orders') active @endif" href="{{ route('order.index', session('gp_orders')) }}">Площадки</a></li>
     @endif
 
-    @if(hasAccess('maf.view', 'admin,manager'))
+    @if(hasPermission('maf.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'product_sku') active @endif"
                                 href="{{ route('product_sku.index', session('gp_product_sku')) }}">МАФ</a></li>
     @endif
-    @if(hasAccess('catalog.view', 'admin,manager'))
+    @if(hasPermission('catalog.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'catalog') active @endif"
                                 href="{{ route('catalog.index', session('gp_products')) }}">Каталог</a></li>
     @endif
-    @if(hasAccess('reports.view', 'admin,manager'))
+    @if(hasPermission('reports.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'reports') active @endif"
                                 href="{{ route('reports.index', session('gp_reports')) }}">Отчёты</a></li>
     @endif
-    @if(hasAccess('responsibles.view', 'admin,manager'))
+    @if(hasPermission('responsibles.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'responsibles') active @endif" href="{{ route('responsible.index', session('gp_responsibles')) }}">Ответственные</a></li>
     @endif
-    @if(hasAccess('reclamations.view', \App\Models\Role::VALID_ROLES))
+    @if(hasPermission('reclamations.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
                                 href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
     @endif
 
-    @if(hasAccess('spare_parts.view', 'admin,manager'))
+    @if(hasPermission('spare_parts.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
                                 href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">Запчасти</a></li>
     @endif
 
-    @if(hasAccess('schedule.view', \App\Models\Role::VALID_ROLES))
+    @if(hasPermission('schedule.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"
                                 href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>
     @endif
 
 
-    @if(hasAccess('maf_orders.view', 'admin'))
+    @if(hasPermission('maf_orders.view'))
         <li class="nav-item"><a class="nav-link @if($active == 'maf_order') active @endif"
                                 href="{{ route('maf_order.index', session('gp_maf_order')) }}">Заказы МАФ</a></li>
     @endif
-    @if(hasAccess('contractors.view', \App\Models\Role::ASSISTANT_HEAD) && !hasRole('admin'))
+    @if(hasPermission('contractors.view') && !hasAnyPermission([
+        'contracts.view',
+        'users.view',
+        'admin.settings.view',
+        'districts.view',
+        'areas.view',
+        'admin.notification_logs.view',
+        'import.view',
+        'admin.year_data.view',
+        'admin.clear_data.view',
+    ]))
         <li class="nav-item"><a class="nav-link @if($active == 'contractors') active @endif"
                                 href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
     @endif
-    @if(hasRole('admin') || hasAnyPermission([
+    @if(hasAnyPermission([
         'contractors.view',
         'contracts.view',
         'users.view',
-{{--        'admin.roles',--}}
+        'admin.roles',
         'admin.settings.view',
         'districts.view',
         'areas.view',
@@ -61,17 +71,17 @@
                 Администрирование
             </a>
             <ul class="dropdown-menu dropdown-menu-end">
-                @if(hasAccess('contractors.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>@endif
-                @if(hasAccess('contracts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>@endif
-                @if(hasAccess('users.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>@endif
-{{--                @if(hasAccess('admin.roles', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>@endif--}}
-                @if(hasAccess('admin.settings.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>@endif
-                @if(hasAccess('districts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>@endif
-                @if(hasAccess('areas.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>@endif
-                @if(hasAccess('admin.notification_logs.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>@endif
-                @if(hasAccess('import.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>@endif
-                @if(hasAccess('admin.year_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>@endif
-                @if(hasAccess('admin.clear_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>@endif
+                @if(hasPermission('contractors.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>@endif
+                @if(hasPermission('contracts.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>@endif
+                @if(hasPermission('users.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>@endif
+                @if(hasPermission('admin.roles'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>@endif
+                @if(hasPermission('admin.settings.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>@endif
+                @if(hasPermission('districts.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>@endif
+                @if(hasPermission('areas.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>@endif
+                @if(hasPermission('admin.notification_logs.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>@endif
+                @if(hasPermission('import.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>@endif
+                @if(hasPermission('admin.year_data.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>@endif
+                @if(hasPermission('admin.clear_data.view'))<li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>@endif
             </ul>
         </li>
 

+ 3 - 3
resources/views/maf_orders/index.blade.php

@@ -10,7 +10,7 @@
             @include('partials.year-switcher')
         </div>
         <div class="col-auto col-md-4 text-md-end page-header-actions">
-            @if(hasRole('admin,assistant_head'))
+            @if(hasPermission('maf_orders.stock.manage'))
                 <button type="button" class="btn btn-sm btn-outline-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#setOrderInStockModal" aria-label="Весь заказ на складе">
                     <i class="bi bi-box-seam page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Весь заказ на складе</span>
@@ -60,7 +60,7 @@
         </div>
     </div>
 
-    @if(hasRole('admin,assistant_head'))
+    @if(hasPermission('maf_orders.stock.manage'))
         <div class="modal fade" id="setOrderInStockModal" tabindex="-1" aria-labelledby="setOrderInStockModalLabel" aria-hidden="true">
             <div class="modal-dialog">
                 <div class="modal-content">
@@ -238,7 +238,7 @@
             });
         });
 
-        @if($errors->has('bulk_order_number') && hasRole('admin,assistant_head'))
+        @if($errors->has('bulk_order_number') && hasPermission('maf_orders.stock.manage'))
             const setOrderInStockModalElement = document.getElementById('setOrderInStockModal');
             if (setOrderInStockModalElement) {
                 const setOrderInStockModal = new bootstrap.Modal(setOrderInStockModalElement);

+ 14 - 14
resources/views/orders/edit.blade.php

@@ -17,27 +17,27 @@
 
                 @include('partials.select', ['name' => 'order_status_id', 'title' => 'Статус', 'options' => $orderStatuses, 'value' => $order->order_status_id ?? old('order_status_id'), 'required' => true])
 
-                @include('partials.select', ['name' => 'district_id', 'title' => 'Округ', 'options' => $districts, 'value' => $order?->district_id ?? old('district_id'), 'first_empty' => true, 'required' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.select', ['name' => 'district_id', 'title' => 'Округ', 'options' => $districts, 'value' => $order?->district_id ?? old('district_id'), 'first_empty' => true, 'required' => true, 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.select', ['name' => 'area_id', 'title' => 'Район', 'options' => $areas, 'value' => $order?->area_id ?? old('area_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.select', ['name' => 'area_id', 'title' => 'Район', 'options' => $areas, 'value' => $order?->area_id ?? old('area_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.input', ['name' => 'object_address', 'title' => 'Адрес объекта', 'value' => $order->object_address ?? old('object_address'), 'required' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.input', ['name' => 'object_address', 'title' => 'Адрес объекта', 'value' => $order->object_address ?? old('object_address'), 'required' => true, 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.input', ['name' => 'name', 'title' => 'Название', 'value' => $order->name ?? old('name'), 'required' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.input', ['name' => 'name', 'title' => 'Название', 'value' => $order->name ?? old('name'), 'required' => true, 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.select', ['name' => 'object_type_id', 'title' => 'Тип объекта', 'options' => $objectTypes, 'value' => $order->object_type_id ?? old('object_type_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.select', ['name' => 'object_type_id', 'title' => 'Тип объекта', 'options' => $objectTypes, 'value' => $order->object_type_id ?? old('object_type_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasPermission('orders.update')])
 
                 @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'value' => $order->comment ?? old('comment')])
 
-                @include('partials.input', ['name' => 'installation_date', 'title' => 'Дата выхода на монтаж', 'type' => 'date', 'value' => $order->installation_date ?? old('installation_date'), 'disabled' => !hasRole('admin')])
+                @include('partials.input', ['name' => 'installation_date', 'title' => 'Дата выхода на монтаж', 'type' => 'date', 'value' => $order->installation_date ?? old('installation_date'), 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasRole('admin')])
+                @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin,manager')])
+                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasRole('admin')])
+                @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasPermission('orders.update')])
 
-                @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $order->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin')])
+                @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $order->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasPermission('orders.update')])
 
                 @include('partials.input', ['name' => 'tg_group_name', 'title' => 'Название группы в ТГ', 'value' => $order->tg_group_name ?? old('tg_group_name')])
 
@@ -50,8 +50,8 @@
                 @endphp
                 <h4>МАФ</h4>
                 <div>
-                    <input type="text" class="form-control mb-2" @disabled((($order->order_status_id ?? 0) > 1) || !hasRole('admin') || $hasAttachedMaf) placeholder="Поиск номенклатуры" id="search_maf">
-                    <select id="select_maf" class="form-select mb-3" multiple @disabled((($order->order_status_id ?? 0) > 1) || !hasRole('admin') || $hasAttachedMaf)></select>
+                    <input type="text" class="form-control mb-2" @disabled((($order->order_status_id ?? 0) > 1) || !hasPermission('orders.update') || $hasAttachedMaf) placeholder="Поиск номенклатуры" id="search_maf">
+                    <select id="select_maf" class="form-select mb-3" multiple @disabled((($order->order_status_id ?? 0) > 1) || !hasPermission('orders.update') || $hasAttachedMaf)></select>
                     @if($hasAttachedMaf)
                         <div class="small text-warning mb-2">Добавление новых позиций МАФ недоступно, пока есть привязанные МАФ.</div>
                     @endif
@@ -71,10 +71,10 @@
                                 </div>
                                 <div class="col-1 d-flex justify-content-end">
                                     <div>
-                                        <input @disabled((($order->order_status_id ?? 0) > 1) || !hasRole('admin')) class="form-control text-end form-control-sm quantity" type="number" name="quantity[]" value="1" @disabled($order->order_status_id > 1)>
+                                        <input @disabled((($order->order_status_id ?? 0) > 1) || !hasPermission('orders.update')) class="form-control text-end form-control-sm quantity" type="number" name="quantity[]" value="1" @disabled($order->order_status_id > 1)>
                                     </div>
                                     <div class="p-1">
-                                        @if(($order->order_status_id == 1) && hasRole('admin') && !$p->maf_order_id)
+                                        @if(($order->order_status_id == 1) && hasPermission('orders.update') && !$p->maf_order_id)
                                             <i onclick="$(this).parent().parent().parent().remove(); $('.changes-message').removeClass('visually-hidden');" class="bi bi-trash text-danger cursor-pointer"></i>
                                         @elseif($p->maf_order_id)
                                             <i class="bi bi-lock text-secondary" title="Привязанный МАФ нельзя удалить"></i>

+ 3 - 3
resources/views/orders/index.blade.php

@@ -9,13 +9,13 @@
             @include('partials.year-switcher')
         </div>
         <div class="col-auto col-md-4 text-md-end page-header-actions">
-            @if(hasRole('admin'))
+            @if(hasPermission('orders.create'))
                 <a href="{{ route('order.create') }}" class="btn btn-sm btn-primary page-action-btn" aria-label="Создать">
                     <i class="bi bi-plus-lg page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Создать</span>
                 </a>
             @endif
-            @if(hasRole('admin,manager'))
+            @if(hasAnyPermission(['orders.update', 'orders.export']))
                 <button type="button" class="btn btn-sm btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#exportOrdersModal" aria-label="Экспорт">
                     <i class="bi bi-download page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Экспорт</span>
@@ -39,7 +39,7 @@
         ],
     ])
 
-    @if(hasRole('admin,manager'))
+    @if(hasAnyPermission(['orders.update', 'orders.export']))
         <div class="modal fade" id="exportOrdersModal" tabindex="-1" aria-labelledby="exportOrdersModalLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
                 <div class="modal-content">

+ 38 - 38
resources/views/orders/show.blade.php

@@ -15,17 +15,17 @@
                 </h3>
             </div>
             <div class="col-md-6 action-toolbar">
-                @if(hasRole('admin,manager'))
+                @if(hasPermission('orders.export'))
                     <a href="{{ route('order.edit', ['order' => $order, 'nav' => $nav ?? null]) }}"
                        class="btn btn-sm mb-1 btn-primary">Редактировать</a>
                 @endif
-                @if(hasRole('admin'))
+                @if(hasPermission('orders.update'))
                     <a href="#" class="btn btn-sm btn-primary mb-1" onclick="$('#export-order').submit()">Экспорт МАФ</a>
                     <form class="d-none" method="post" action="{{ route('order.export-one', $order) }}" id="export-order">
                         @csrf
                     </form>
                 @endif
-                @if(hasRole('admin') && ($order->order_status_id == Order::STATUS_NEW))
+                @if(hasPermission('orders.delete') && ($order->order_status_id == Order::STATUS_NEW))
                     <a href="#" onclick="customConfirm('Удалить площадку?', function () { $('form#destroy').submit(); }, 'Подтверждение удаления'); return false;"
                        class="btn btn-sm mb-1 btn-danger">Удалить</a>
                     <form action="{{ route('order.destroy', $order) }}" method="post" class="d-none" id="destroy">
@@ -33,16 +33,16 @@
                         @method('DELETE')
                     </form>
                 @endif
-                @if(hasRole('admin,manager'))
+                @if(hasPermission('orders.documents.generate'))
                     <a href="{{ route('order.generate-installation-pack', $order) }}"
                        class="btn btn-sm mb-1 btn-primary">Документы для монтажа</a>
                 @endif
-                @if(hasRole('admin'))
+                @if(hasPermission('schedule.create'))
                     <button class="btn btn-primary btn-sm mb-1"
                             id="createScheduleButton">Перенести в график
                     </button>
                 @endif
-                @if(hasRole('admin,manager'))
+                @if(hasPermission('orders.documents.generate'))
                     <a href="{{ route('order.generate-handover-pack', $order) }}" class="btn btn-sm mb-1 btn-primary">Документы
                         для сдачи</a>
                 @endif
@@ -57,22 +57,22 @@
                 <h4>Общая информация об объекте</h4>
                 <div>ID площадки: {{ $order->id }}</div>
 
-                @include('partials.input',  ['name' => 'name', 'title' => 'Название', 'value' => $order->name ?? old('name'), 'required' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.input',  ['name' => 'object_address', 'title' => 'Адрес объекта', 'value' => $order->object_address ?? old('object_address'), 'required' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
+                @include('partials.input',  ['name' => 'name', 'title' => 'Название', 'value' => $order->name ?? old('name'), 'required' => true, 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.input',  ['name' => 'object_address', 'title' => 'Адрес объекта', 'value' => $order->object_address ?? old('object_address'), 'required' => true, 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
                 @include('partials.input',  ['name' => 'district_name', 'title' => 'Округ', 'value' => $order->district?->name, 'disabled' => true])
                 @include('partials.input',  ['name' => 'area_name', 'title' => 'Район', 'value' => $order->area?->name, 'disabled' => true])
-                @include('partials.select', ['name' => 'object_type_id', 'title' => 'Тип объекта', 'options' => $objectTypes, 'value' => $order->object_type_id ?? old('object_type_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.select', ['name' => 'order_status_id', 'title' => 'Статус', 'options' => $orderStatuses, 'value' => $order->order_status_id ?? old('order_status_id'), 'required' => true, 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
-                @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'value' => $order->comment ?? old('comment'), 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
-                @include('partials.input', ['name' => 'installation_date', 'title' => 'Дата выхода на монтаж', 'type' => 'date', 'value' => $order->installation_date ?? old('installation_date'), 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $order->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin'), 'classes' => ['update-once']])
-                @include('partials.input', ['name' => 'tg_group_name', 'title' => 'Название группы в ТГ', 'value' => $order->tg_group_name ?? old('tg_group_name'), 'classes' => ['update-once'], 'disabled' => !hasRole('admin,manager')])
-                @include('partials.input', ['name' => 'tg_group_link', 'title' => 'https://t.me/', 'value' => $order->tg_group_link ?? old('tg_group_link'), 'classes' => ['update-once'], 'button' => (!empty($order->tg_group_link)) ? 'tg' : null, 'buttonText' => '<i class="bi bi-telegram"></i>', 'disabled' => !hasRole('admin,manager')])
-
-                @if(!hasRole('brigadier'))
+                @include('partials.select', ['name' => 'object_type_id', 'title' => 'Тип объекта', 'options' => $objectTypes, 'value' => $order->object_type_id ?? old('object_type_id'), 'required' => true, 'first_empty' => true, 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.select', ['name' => 'order_status_id', 'title' => 'Статус', 'options' => $orderStatuses, 'value' => $order->order_status_id ?? old('order_status_id'), 'required' => true, 'classes' => ['update-once'], 'disabled' => !hasPermission('orders.update')])
+                @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'value' => $order->comment ?? old('comment'), 'classes' => ['update-once'], 'disabled' => !hasPermission('orders.update')])
+                @include('partials.input', ['name' => 'installation_date', 'title' => 'Дата выхода на монтаж', 'type' => 'date', 'value' => $order->installation_date ?? old('installation_date'), 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.input', ['name' => 'install_days', 'title' => 'Дней на монтаж', 'type' => 'number', 'min' => 1, 'value' => $order->install_days ?? old('install_days'), 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.input', ['name' => 'ready_date', 'title' => 'Дата готовности площадки', 'type' => 'date', 'value' => $order->ready_date ?? old('ready_date'), 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $order->brigadier_id ?? old('brigadier_id'), 'first_empty' => true, 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $order->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasPermission('orders.update'), 'classes' => ['update-once']])
+                @include('partials.input', ['name' => 'tg_group_name', 'title' => 'Название группы в ТГ', 'value' => $order->tg_group_name ?? old('tg_group_name'), 'classes' => ['update-once'], 'disabled' => !hasPermission('orders.update')])
+                @include('partials.input', ['name' => 'tg_group_link', 'title' => 'https://t.me/', 'value' => $order->tg_group_link ?? old('tg_group_link'), 'classes' => ['update-once'], 'button' => (!empty($order->tg_group_link)) ? 'tg' : null, 'buttonText' => '<i class="bi bi-telegram"></i>', 'disabled' => !hasPermission('orders.update')])
+
+                @if(visibilityScope('orders') !== 'brigadier')
                     <hr>
                     <div class="reclamations">
                         Рекламации
@@ -85,7 +85,7 @@
                         @endforeach
                     </div>
                 @endif
-                @if(hasRole('admin,manager'))
+                @if(hasPermission('orders.documents.upload'))
                     <hr>
                     <div class="documents">
                         Документы
@@ -98,7 +98,7 @@
                             <input required type="file" id="upl-documents" onchange="$(this).parent().submit()" multiple
                                    name="document[]" class="form-control form-control-sm">
                         </form>
-                        @if(hasRole('admin'))
+                        @if(hasPermission('orders.documents.delete'))
                             <button class="btn btn-sm text-danger" onclick="customConfirm('Удалить все документы?', function () { $('#delete-all-documents').submit(); }, 'Подтверждение удаления'); return false;"><i
                                         class="bi bi-file-minus-fill"></i> Удалить все
                             </button>
@@ -114,7 +114,7 @@
                                     <a href="{{ $document->link }}" target="_blank">
                                         {{ $document->original_name }}
                                     </a>
-                                    @if(hasRole('admin,manager'))
+                                    @if(hasPermission('orders.documents.delete'))
                                         <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer ms-2"
                                            onclick="customConfirm('Удалить?', function () { $('#document-{{ $document->id }}').submit(); }, 'Подтверждение удаления')"
                                            title="Удалить"></i>
@@ -140,7 +140,7 @@
                             <input required type="file" id="upl-statements" onchange="$(this).parent().submit()" multiple
                                    name="statement[]" class="form-control form-control-sm">
                         </form>
-                        @if(hasRole('admin'))
+                        @if(hasPermission('orders.documents.delete'))
                             <button class="btn btn-sm text-danger" onclick="customConfirm('Удалить все ведомости?', function () { $('#delete-all-statements').submit(); }, 'Подтверждение удаления'); return false;"><i
                                         class="bi bi-file-minus-fill"></i> Удалить все
                             </button>
@@ -156,7 +156,7 @@
                                     <a href="{{ $statement->link }}" target="_blank">
                                         {{ $statement->original_name }}
                                     </a>
-                                    @if(hasRole('admin,manager'))
+                                    @if(hasPermission('orders.documents.delete'))
                                         <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer ms-2"
                                            onclick="customConfirm('Удалить?', function () { $('#statement-{{ $statement->id }}').submit(); }, 'Подтверждение удаления')"
                                            title="Удалить"></i>
@@ -191,7 +191,7 @@
                         <input required type="file" id="upl-photo" onchange="$(this).parent().submit()" multiple
                                name="photo[]" class="form-control form-control-sm" accept=".jpg,.jpeg,.png,.webp">
                     </form>
-                    @if(hasRole('admin'))
+                    @if(hasPermission('orders.photos.delete'))
                         <button class="btn btn-sm text-danger" onclick="customConfirm('Удалить все фотографии?', function () { $('#delete-all-photos').submit(); }, 'Подтверждение удаления'); return false;"><i
                                     class="bi bi-file-minus-fill"></i> Удалить все
                         </button>
@@ -208,7 +208,7 @@
                                    data-toggle="lightbox" data-gallery="photos" data-size="fullscreen">
                                     <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
-                                @if(hasRole('admin,manager'))
+                                @if(hasPermission('orders.photos.delete'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"
                                        onclick="customConfirm('Удалить фото?', function () { $('#photo-{{ $photo->id }}').submit(); }, 'Подтверждение удаления')"
                                        title="Удалить"></i>
@@ -269,7 +269,7 @@
                                             @endif
                                         </td>
                                         <td>
-                                            @if(hasRole('admin'))
+                                            @if(hasPermission('maf.view'))
                                                 <a href="{{ route('product_sku.show', ['product_sku' =>$p, 'nav' => $nav ?? null]) }}">
                                                     {!! $p->product->article !!}
                                                 </a>
@@ -283,7 +283,7 @@
                                         <td>{!! $p->product->nomenclature_number !!}</td>
                                         <td>{{ $p->status }}</td>
                                         <td>
-                                            @if($p->maf_order_id && hasRole('admin'))
+                                            @if($p->maf_order_id && hasPermission('maf_orders.view'))
                                                 <a href="{{ route('maf_order.show', $p->maf_order) }}">{{ $p->maf_order->order_number }}</a>
                                             @else
                                                 {{ $p->maf_order?->order_number }}
@@ -425,7 +425,7 @@
                             </div>
                         @endif
                         <div>
-                            @if(hasRole('admin'))
+                            @if(hasPermission('orders.maf.manage'))
                                 <a href="{{ route('order.get-maf', $order) }}"
                                    class="btn btn-primary btn-sm mb-1">Привязать
                                     все МАФы</a>
@@ -438,7 +438,7 @@
                                 </button>
                                 <br class="d-md-none">
                             @endif
-                            @if(hasRole('admin,manager'))
+                            @if(hasPermission('reclamations.create'))
                                 <button class="btn btn-sm mb-1 btn-warning" id="create-reclamation-button">Создать
                                     рекламацию
                                 </button>
@@ -449,13 +449,13 @@
                                 </form>
                                 <br class="d-md-none">
                             @endif
-                            @if(hasRole('admin'))
+                            @if(hasPermission('orders.ttn.create'))
                                 <a href="#" class="btn btn-primary btn-sm mb-1" id="ttnBtn">ТН</a>
                             @endif
                             @if(canUpdateField('maf', 'statement_number') && canUpdateField('maf', 'statement_date'))
                                 <a href="#" class="btn btn-primary btn-sm mb-1" id="addStatementToMafsBtn">Добавить Ведомость</a>
                             @endif
-                            @if(hasRole('admin,assistant_head'))
+                            @if(hasPermission('orders.contractor_specification.create'))
                                 <a href="#" class="btn btn-primary btn-sm mb-1" id="contractorSpecificationBtn">Спецификация</a>
                             @endif
                                 <br class="d-md-none">
@@ -483,7 +483,7 @@
         </div>
     </div>
 
-    @if(hasRole('admin,assistant_head'))
+    @if(hasPermission('orders.contractor_specification.create'))
         <!-- Модальное окно графика -->
         <div class="modal fade" id="copySchedule" tabindex="-1" aria-labelledby="copyScheduleLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
@@ -611,7 +611,7 @@
         </div>
     @endif
 
-    @if(hasRole('admin,assistant_head'))
+    @if(hasPermission('orders.contractor_specification.create'))
         <style>
             #select_order {
                 max-width: 100%;
@@ -654,7 +654,7 @@
             window.open('https://t.me/{{ $order->tg_group_link }}', '_blank');
         });
 
-        @if(hasRole('admin'))
+        @if(hasPermission('schedule.create'))
             // select order
             $('#search_order').on('keyup', function () {
                 // search products on backend
@@ -676,7 +676,7 @@
             $('input:checkbox.check-maf').not(this).prop('checked', this.checked);
         });
 
-        @if(hasRole('admin'))
+        @if(hasPermission('orders.ttn.create'))
             // move maf
             $('#moveMaf').on('click', function () {
                 let ids = Array();
@@ -748,7 +748,7 @@
             }
         });
 
-        @if(hasRole('admin,assistant_head'))
+        @if(hasPermission('orders.contractor_specification.create'))
             $('#createScheduleButton').on('click', function () {
                 let ids = Array();
                 $('.check-maf').each(function () {

+ 4 - 13
resources/views/partials/chat.blade.php

@@ -9,8 +9,8 @@
     $contextKey = $contextKey ?? 'chat';
     $title = $title ?? 'Чат';
     $submitLabel = $submitLabel ?? 'Отправить';
-    $canSendNotifications = hasRole('admin,manager');
-    $canDeleteMessages = hasRole('admin') && !empty($action);
+    $canSendNotifications = hasPermission('chat_messages.notify');
+    $canDeleteMessages = hasAnyPermission(['orders.chat.delete', 'reclamations.chat.delete']) && !empty($action);
     $notificationValue = old('notification_type', \App\Models\ChatMessage::NOTIFICATION_NONE);
     $notificationEnabled = $canSendNotifications && $notificationValue !== \App\Models\ChatMessage::NOTIFICATION_NONE;
     $showAllUsers = $notificationValue === \App\Models\ChatMessage::NOTIFICATION_ALL;
@@ -21,16 +21,7 @@
         ->values()
         ->all();
 
-    // Роли в порядке сортировки
-    $roleOrder = [\App\Models\Role::ADMIN, \App\Models\Role::MANAGER, \App\Models\Role::BRIGADIER];
-    $roleNames = \App\Models\Role::NAMES;
-
-    // Сортировка пользователей: админы -> менеджеры -> бригадиры -> остальные
-    $sortedUsers = $users->sortBy(function ($user) use ($roleOrder) {
-        $role = $user['role'] ?? '';
-        $index = array_search($role, $roleOrder, true);
-        return $index === false ? 999 : $index;
-    });
+    $sortedUsers = $users->sortBy('name');
 @endphp
 
 <div class="chat-block mt-3" data-chat-block data-context-key="{{ $contextKey }}">
@@ -199,7 +190,7 @@
                                     $userRole = $userData['role'] ?? '';
                                     $isManagerOfEntity = $managerUserId && (int) $userId === $managerUserId;
                                     $isBrigadierOfEntity = $brigadierUserId && (int) $userId === $brigadierUserId;
-                                    $roleLabel = $roleNames[$userRole] ?? '';
+                                    $roleLabel = $userRole ? roleName($userRole) : '';
                                     $displayLabel = $userData['name'];
                                     if ($roleLabel) {
                                         $displayLabel .= ' (' . $roleLabel . ')';

+ 2 - 2
resources/views/partials/table.blade.php

@@ -170,7 +170,7 @@
                                 <img src="{{ $string->$headerName }}" alt="" class="img-thumbnail maf-img">
                             </a>
                         @elseif(str_contains($headerName, 'order_status_name'))
-                            <select name="order_status_name" data-order-id="{{ $string->id }}" @disabled(!hasRole('admin,manager')) class="change-order-status form-control form-control-sm" >
+                            <select name="order_status_name" data-order-id="{{ $string->id }}" @disabled(!hasPermission('orders.update')) class="change-order-status form-control form-control-sm" >
                                 @foreach($statuses as $statusId => $statusName)
                                     <option value="{{ $statusId }}" @selected($statusName == $string->$headerName)>{{ $statusName }}</option>
                                 @endforeach
@@ -179,7 +179,7 @@
                             <select name="status_id"
                                     data-reclamation-id="{{ $string->id }}"
                                     data-url="{{ route('reclamations.update-status', $string->id) }}"
-                                    @disabled(!hasRole('admin,manager'))
+                                    @disabled(!hasPermission('orders.update'))
                                     class="change-reclamation-status form-control form-control-sm">
                                 @foreach($statuses as $statusId => $statusName)
                                     <option value="{{ $statusId }}" @selected($statusId == $string->status_id)>{{ $statusName }}</option>

+ 9 - 9
resources/views/products_sku/edit.blade.php

@@ -8,7 +8,7 @@
                 <h4>МАФ на складе ({{ $product_sku->year }})</h4>
             </div>
             <div class="col-xl-6 text-end">
-                @if(isset($product_sku) && hasRole('admin'))
+                @if(isset($product_sku) && hasPermission('maf.passports.upload'))
                     <button class="btn btn-sm text-success" onclick="$('#upl-pass').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить паспорт</button>
 
                     <form action="{{ route('product-sku.upload-passport', ['product_sku' => $product_sku, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
@@ -29,12 +29,12 @@
                 @include('partials.input', ['name' => 'year', 'title' => 'Год', 'value' => $product_sku->year, 'disabled' => true])
                 @include('partials.link',  ['href' => route('order.show', ['order' => $product_sku->order_id, 'nav' => $nav ?? null]), 'title' => 'Площадка', 'text' => $product_sku->order->common_name])
                 @include('partials.input', ['name' => 'product_name', 'title' => 'МАФ', 'disabled' => true, 'value' => $product_sku->product->common_name])
-                @include('partials.input', ['name' => 'rfid', 'title' => 'RFID', 'required' => true, 'disabled' => !hasRole('admin'), 'value' => $product_sku->rfid])
-                @include('partials.input', ['name' => 'factory_number', 'title' => 'Номер фабрики', 'required' => true, 'disabled' => !hasRole('admin'), 'value' => $product_sku->factory_number])
-                @include('partials.input', ['name' => 'manufacture_date', 'title' => 'Дата производства', 'type' => 'date', 'required' => true, 'disabled' => !hasRole('admin'), 'value' => $product_sku->manufacture_date])
-                @include('partials.input', ['name' => 'statement_number', 'title' => 'Номер ведомости', 'disabled' => !hasRole('admin'), 'value' => $product_sku->statement_number])
-                @include('partials.input', ['name' => 'statement_date', 'title' => 'Дата ведомости', 'disabled' => !hasRole('admin'), 'type' => 'date', 'value' => $product_sku->statement_date])
-                @include('partials.input', ['name' => 'upd_number', 'title' => 'Номер УПД', 'disabled' => !hasRole('admin'), 'value' => $product_sku->upd_number])
+                @include('partials.input', ['name' => 'rfid', 'title' => 'RFID', 'required' => true, 'disabled' => !hasPermission('maf.update'), 'value' => $product_sku->rfid])
+                @include('partials.input', ['name' => 'factory_number', 'title' => 'Номер фабрики', 'required' => true, 'disabled' => !hasPermission('maf.update'), 'value' => $product_sku->factory_number])
+                @include('partials.input', ['name' => 'manufacture_date', 'title' => 'Дата производства', 'type' => 'date', 'required' => true, 'disabled' => !hasPermission('maf.update'), 'value' => $product_sku->manufacture_date])
+                @include('partials.input', ['name' => 'statement_number', 'title' => 'Номер ведомости', 'disabled' => !hasPermission('maf.update'), 'value' => $product_sku->statement_number])
+                @include('partials.input', ['name' => 'statement_date', 'title' => 'Дата ведомости', 'disabled' => !hasPermission('maf.update'), 'type' => 'date', 'value' => $product_sku->statement_date])
+                @include('partials.input', ['name' => 'upd_number', 'title' => 'Номер УПД', 'disabled' => !hasPermission('maf.update'), 'value' => $product_sku->upd_number])
             </div>
             <div class="col-xxl-6">
                 @if($product_sku->passport)
@@ -45,7 +45,7 @@
                         Примечание
                     </label>
                     <div>
-                        <textarea name="comment" id="comment" @disabled(!hasRole('admin')) rows="15" class="form-control @error('comment') is-invalid @enderror">{{ old('note', $product_sku->comment ?? '') }}</textarea>
+                        <textarea name="comment" id="comment" @disabled(!hasPermission('maf.update')) rows="15" class="form-control @error('comment') is-invalid @enderror">{{ old('note', $product_sku->comment ?? '') }}</textarea>
                         @error('comment')
                         <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
                         @enderror
@@ -54,7 +54,7 @@
 
             </div>
             <div class="col-12">
-                @include('partials.submit', ['name' => 'Сохранить', 'offset' => 5, 'disabled' => !hasRole('admin'), 'back_url' => $back_url ?? route('product_sku.index', session('gp_product_sku'))])
+                @include('partials.submit', ['name' => 'Сохранить', 'offset' => 5, 'disabled' => !hasPermission('maf.update'), 'back_url' => $back_url ?? route('product_sku.index', session('gp_product_sku'))])
             </div>
         </form>
 @endsection

+ 2 - 2
resources/views/products_sku/index.blade.php

@@ -10,7 +10,7 @@
             @include('partials.year-switcher')
         </div>
         <div class="col-auto col-md-4 text-md-end page-header-actions">
-            @if(hasRole('admin'))
+            @if(hasAnyPermission(['maf.import', 'maf.export']))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#importModal" aria-label="Импорт">
                     <i class="bi bi-upload page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Импорт</span>
@@ -20,7 +20,7 @@
                     <span class="page-action-btn__label">Экспорт</span>
                 </button>
             @endif
-            @if(hasRole('admin,assistant_head'))
+            @if(hasPermission('maf.registry.export'))
                 <button type="button" class="btn btn-sm mb-1 btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#registryModal" aria-label="Реестр на оплату">
                     <i class="bi bi-file-earmark-spreadsheet page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Реестр на оплату</span>

+ 46 - 46
resources/views/reclamations/edit.blade.php

@@ -13,7 +13,7 @@
                 </h4>
             </div>
             <div class="col-xl-6 action-toolbar mb-2">
-                @if(hasRole('admin'))
+                @if(hasPermission('reclamations.delete'))
                     <a href="#" onclick="customConfirm('Удалить рекламацию?', function () { $('form#destroy').submit(); }, 'Подтверждение удаления'); return false;"
                        class="btn btn-sm btn-danger">Удалить</a>
                     <form action="{{ route('reclamations.delete', $reclamation) }}" method="post" class="d-none" id="destroy">
@@ -21,10 +21,10 @@
                         @method('DELETE')
                     </form>
                 @endif
-                @if(hasRole('admin') && !is_null($reclamation->brigadier_id) && !is_null($reclamation->start_work_date))
+                @if(hasPermission('schedule.create') && !is_null($reclamation->brigadier_id) && !is_null($reclamation->start_work_date))
                     <button class="btn btn-sm btn-primary" id="createScheduleButton">Перенести в график</button>
                 @endif
-                @if(hasRole('admin,manager'))
+                @if(hasPermission('reclamations.update'))
                     <a href="{{ route('order.generate-reclamation-pack', ['reclamation' => $reclamation, 'nav' => $nav ?? null]) }}"
                        class="btn btn-primary btn-sm">Пакет документов рекламации</a>
                     <a href="{{ route('reclamation.generate-reclamation-payment-pack', ['reclamation' => $reclamation, 'nav' => $nav ?? null]) }}"
@@ -43,20 +43,20 @@
                     <input type="hidden" name="nav" value="{{ $nav ?? '' }}">
 
                     @include('partials.link', ['title' => 'Площадка', 'href' => route('order.show', ['order' => $reclamation->order_id, 'sync_year' => 1, 'nav' => $nav ?? null]), 'text' => $reclamation->order->common_name ?? ''])
-                    @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'status_id', 'title' => 'Статус', 'options' => $statuses, 'value' => $reclamation->status_id ?? old('status_id'), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'user_id', 'title' => 'Менеджер', 'options' => $users, 'value' => $reclamation->user_id ?? old('user_id') ?? auth()->user()->id, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
                     @include('partials.input', ['name' => 'maf_installation_year', 'title' => 'Год установки МАФ', 'type' => 'text', 'value' => $reclamation->order->year, 'disabled' => true])
-                    @include('partials.input', ['name' => 'create_date', 'title' => 'Дата создания', 'type' => 'date', 'required' => true, 'value' => $reclamation->create_date ?? date('Y-m-d'), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.input', ['name' => 'finish_date', 'title' => 'Дата завершения', 'type' => 'date', 'required' => true, 'value' => $reclamation->finish_date ?? date('Y-m-d', strtotime('+30 days')), 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $reclamation->brigadier_id ?? old('brigadier_id'), 'disabled' => !hasRole('admin,manager'), 'first_empty' => true, 'classes' => ['update-once']])
-                    @include('partials.input', ['name' => 'start_work_date', 'title' => 'Дата начала работ', 'type' => 'date', 'value' => $reclamation->start_work_date, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.input', ['name' => 'work_days', 'title' => 'Срок работ, дней', 'type' => 'number', 'min' => 1, 'value' => $reclamation->work_days, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.select', ['name' => 'reason', 'title' => 'Причина', 'size' => 6, 'value' => $reclamation->reason ?? '', 'options' => ['Вандализм', 'Гарантия', 'Сервисное обслуживание'], 'key_as_val' => true, 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.input', ['name' => 'factory_reclamation_number', 'title' => '№ рекламации на фабрике', 'type' => 'text', 'value' => $reclamation->factory_reclamation_number ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.textarea', ['name' => 'guarantee', 'title' => 'Гарантии', 'size' => 6, 'value' => $reclamation->guarantee ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.textarea', ['name' => 'whats_done', 'title' => 'Что сделано', 'size' => 6, 'value' => $reclamation->whats_done ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'size' => 6, 'value' => $reclamation->comment ?? '', 'disabled' => !hasRole('admin,manager'), 'classes' => ['update-once']])
-                    @include('partials.submit', ['name' => 'Сохранить', 'offset' => 5, 'disabled' => !hasRole('admin,manager'), 'back_url' => $back_url ?? route('reclamations.index', session('gp_reclamations'))])
+                    @include('partials.input', ['name' => 'create_date', 'title' => 'Дата создания', 'type' => 'date', 'required' => true, 'value' => $reclamation->create_date ?? date('Y-m-d'), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'finish_date', 'title' => 'Дата завершения', 'type' => 'date', 'required' => true, 'value' => $reclamation->finish_date ?? date('Y-m-d', strtotime('+30 days')), 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'brigadier_id', 'title' => 'Бригадир', 'options' => $brigadiers, 'value' => $reclamation->brigadier_id ?? old('brigadier_id'), 'disabled' => !hasPermission('reclamations.update'), 'first_empty' => true, 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'start_work_date', 'title' => 'Дата начала работ', 'type' => 'date', 'value' => $reclamation->start_work_date, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'work_days', 'title' => 'Срок работ, дней', 'type' => 'number', 'min' => 1, 'value' => $reclamation->work_days, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.select', ['name' => 'reason', 'title' => 'Причина', 'size' => 6, 'value' => $reclamation->reason ?? '', 'options' => ['Вандализм', 'Гарантия', 'Сервисное обслуживание'], 'key_as_val' => true, 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.input', ['name' => 'factory_reclamation_number', 'title' => '№ рекламации на фабрике', 'type' => 'text', 'value' => $reclamation->factory_reclamation_number ?? '', 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'guarantee', 'title' => 'Гарантии', 'size' => 6, 'value' => $reclamation->guarantee ?? '', 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'whats_done', 'title' => 'Что сделано', 'size' => 6, 'value' => $reclamation->whats_done ?? '', 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.textarea', ['name' => 'comment', 'title' => 'Комментарий', 'size' => 6, 'value' => $reclamation->comment ?? '', 'disabled' => !hasPermission('reclamations.update'), 'classes' => ['update-once']])
+                    @include('partials.submit', ['name' => 'Сохранить', 'offset' => 5, 'disabled' => !hasPermission('reclamations.update'), 'back_url' => $back_url ?? route('reclamations.index', session('gp_reclamations'))])
                 </form>
             </div>
             <div class="col-xl-7 ">
@@ -85,7 +85,7 @@
                                     @endif
                                 </td>
                                 <td>
-                                    @if(hasRole('admin,manager'))
+                                    @if(hasPermission('reclamations.update'))
                                         <a href="{{ route('product_sku.show', ['product_sku' => $p, 'nav' => $nav ?? null]) }}">
                                             {{ $p->product->article }}
                                         </a>
@@ -97,7 +97,7 @@
                                 </td>
                                 <td>{!! $p->product->nomenclature_number !!}</td>
                                 <td>
-                                    @if($p->maf_order_id && hasRole('admin'))
+                                    @if($p->maf_order_id && hasPermission('maf_orders.view'))
                                         <a href="{{ route('maf_order.show', $p->maf_order) }}">{{ $p->maf_order->order_number }}</a>
                                     @else
                                         {{ $p->maf_order?->order_number }}
@@ -138,13 +138,13 @@
                                                value="{{ $sp->article }}@if($sp->note) ({{ $sp->note }})@endif"
                                                placeholder="Введите артикул или название"
                                                autocomplete="off"
-                                               @disabled(!hasRole('admin,manager'))>
+                                               @disabled(!hasPermission('reclamations.update'))>
                                         <div class="spare-part-dropdown"></div>
                                     </div>
                                     <div class="col-4 col-md-2">
                                         <input type="number" name="rows[{{ $idx }}][quantity]" value="{{ $sp->pivot->quantity }}"
                                                min="0" class="form-control form-control-sm text-end"
-                                               @disabled(!hasRole('admin,manager'))
+                                               @disabled(!hasPermission('reclamations.update'))
                                                placeholder="кол-во">
                                     </div>
                                     <div class="col-5 col-md-3">
@@ -153,7 +153,7 @@
                                             <input type="checkbox" name="rows[{{ $idx }}][with_documents]" value="1"
                                                    class="form-check-input" id="with_docs_{{ $idx }}"
                                                    @checked($sp->pivot->with_documents)
-                                                   @disabled(!hasRole('admin,manager'))>
+                                                   @disabled(!hasPermission('reclamations.update'))>
                                             <label class="form-check-label small" for="with_docs_{{ $idx }}">с док.</label>
                                         </div>
                                     </div>
@@ -171,13 +171,13 @@
                                                value=""
                                                placeholder="Введите артикул или название"
                                                autocomplete="off"
-                                               @disabled(!hasRole('admin,manager'))>
+                                               @disabled(!hasPermission('reclamations.update'))>
                                         <div class="spare-part-dropdown"></div>
                                     </div>
                                     <div class="col-4 col-md-2">
                                         <input type="number" name="rows[0][quantity]" value=""
                                                min="0" class="form-control form-control-sm text-end"
-                                               @disabled(!hasRole('admin,manager'))
+                                               @disabled(!hasPermission('reclamations.update'))
                                                placeholder="кол-во">
                                     </div>
                                     <div class="col-5 col-md-3">
@@ -185,7 +185,7 @@
                                             <input type="hidden" name="rows[0][with_documents]" value="0">
                                             <input type="checkbox" name="rows[0][with_documents]" value="1"
                                                    class="form-check-input" id="with_docs_0"
-                                                   @disabled(!hasRole('admin,manager'))>
+                                                   @disabled(!hasPermission('reclamations.update'))>
                                             <label class="form-check-label small" for="with_docs_0">с док.</label>
                                         </div>
                                     </div>
@@ -229,8 +229,8 @@
                         <div class="row">
                             <div class="col-12 text-end">
                                 <span class="btn btn-light btn-sm cursor-pointer" id="add-spare-part-row"
-                                      @disabled(!hasRole('admin,manager'))>Добавить строку</span>
-                                <button class="btn btn-primary btn-sm" type="submit" @disabled(!hasRole('admin,manager'))>Сохранить запчасти</button>
+                                      @disabled(!hasPermission('reclamations.update'))>Добавить строку</span>
+                                <button class="btn btn-primary btn-sm" type="submit" @disabled(!hasPermission('reclamations.update'))>Сохранить запчасти</button>
                             </div>
                         </div>
                     </form>
@@ -260,7 +260,7 @@
                                                 <th class="text-center">Кол-во</th>
                                                 <th class="text-center">С док.</th>
                                                 <th>Номер заказа</th>
-                                                @if(hasRole('admin,manager'))
+                                                @if(hasPermission('reclamations.update'))
                                                     <th></th>
                                                 @endif
                                             </tr>
@@ -283,7 +283,7 @@
                                                 <tr>
                                                     <td>
                                                         @if($reservation->sparePart)
-                                                            @if(hasRole('admin,manager'))
+                                                            @if(hasPermission('reclamations.update'))
                                                                 <a href="{{ route('spare_parts.show', $reservation->sparePart->id) }}">
                                                                     {{ $reservation->sparePart->article }}
                                                                 </a>
@@ -304,7 +304,7 @@
                                                     </td>
                                                     <td>
                                                         @if($reservation->sparePartOrder)
-                                                            @if(hasRole('admin,manager'))
+                                                            @if(hasPermission('reclamations.update'))
                                                                 <a href="{{ route('spare_part_orders.show', $reservation->sparePartOrder->id) }}">
                                                                     {{ $reservation->sparePartOrder->display_order_number }}
                                                                 </a>
@@ -315,7 +315,7 @@
                                                             -
                                                         @endif
                                                     </td>
-                                                    @if(hasRole('admin,manager'))
+                                                    @if(hasPermission('reclamations.update'))
                                                         <td class="text-end">
                                                             <button type="button"
                                                                     class="btn btn-sm btn-outline-primary"
@@ -391,7 +391,7 @@
                                             @endforeach
                                         </tbody>
                                     </table>
-                                    @if(hasRole('admin,manager') && $reservations->where('status', 'active')->count() > 1)
+                                    @if(hasPermission('reclamations.update') && $reservations->where('status', 'active')->count() > 1)
                                         <div class="text-end mt-1">
                                             <form action="{{ route('spare_part_reservations.issue_all', $reclamation->id) }}" method="POST" class="d-inline">
                                                 @csrf
@@ -425,7 +425,7 @@
                                                 <tr>
                                                     <td>
                                                         @if($reservation->sparePart)
-                                                            @if(hasRole('admin,manager'))
+                                                            @if(hasPermission('reclamations.update'))
                                                                 <a href="{{ route('spare_parts.show', $reservation->sparePart->id) }}">
                                                                     {{ $reservation->sparePart->article }}
                                                                 </a>
@@ -446,7 +446,7 @@
                                                     </td>
                                                     <td>
                                                         @if($reservation->sparePartOrder)
-                                                            @if(hasRole('admin,manager'))
+                                                            @if(hasPermission('reclamations.update'))
                                                                 <a href="{{ route('spare_part_orders.show', $reservation->sparePartOrder->id) }}">
                                                                     {{ $reservation->sparePartOrder->display_order_number }}
                                                                 </a>
@@ -484,7 +484,7 @@
                                                 <tr>
                                                     <td>
                                                         @if($shortage->sparePart)
-                                                            @if(hasRole('admin,manager'))
+                                                            @if(hasPermission('reclamations.update'))
                                                                 <a href="{{ route('spare_parts.show', $shortage->sparePart->id) }}">
                                                                     {{ $shortage->sparePart->article }}
                                                                 </a>
@@ -517,7 +517,7 @@
                 <hr>
                 <div class="documents">
                     Документы
-                    @if(hasRole('admin,manager'))
+                    @if(hasPermission('reclamations.update'))
                     <button class="btn btn-sm text-success" onclick="$('#upl-documents').trigger('click');"><i
                                 class="bi bi-plus-circle-fill"></i> Загрузить
                     </button>
@@ -535,7 +535,7 @@
                                 <a href="{{ $document->link }}" target="_blank">
                                     {{ $document->original_name }}
                                 </a>
-                                @if(hasRole('admin,manager'))
+                                @if(hasPermission('reclamations.update'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer ms-2"
                                        onclick="customConfirm('Удалить?', function () { $('#document-{{ $document->id }}').submit(); }, 'Подтверждение удаления')"
                                        title="Удалить"></i>
@@ -553,7 +553,7 @@
                 <hr>
                 <div class="acts">
                     Акты
-                    @if(hasRole('admin,manager,brigadier,warehouse_head'))
+                    @if(hasPermission('reclamations.act.upload'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-acts').trigger('click');"><i
                                     class="bi bi-plus-circle-fill"></i> Загрузить
                         </button>
@@ -571,7 +571,7 @@
                                 <a href="{{ $act->link }}" target="_blank">
                                     {{ $act->original_name }}
                                 </a>
-                                @if(hasRole('admin,manager'))
+                                @if(hasPermission('reclamations.update'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer ms-2"
                                        onclick="customConfirm('Удалить?', function () { $('#act-{{ $act->id }}').submit(); }, 'Подтверждение удаления')"
                                        title="Удалить"></i>
@@ -591,7 +591,7 @@
                 <div class="photo_before">
                     <a href="#photos_before" data-bs-toggle="collapse">Фотографии проблемы
                         ({{ $reclamation->photos_before->count() }})</a>
-                    @if(hasRole('admin,manager'))
+                    @if(hasPermission('reclamations.update'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-photo-before').trigger('click');"><i
                                     class="bi bi-plus-circle-fill"></i> Загрузить
                         </button>
@@ -602,7 +602,7 @@
                                     class="bi bi-download"></i> Скачать все
                         </a>
                     @endif
-                    @if(hasRole('admin,manager'))
+                    @if(hasPermission('reclamations.update'))
                         <form action="{{ route('reclamations.upload-photo-before', $reclamation) }}"
                               enctype="multipart/form-data" method="post" class="visually-hidden">
                             @csrf
@@ -618,7 +618,7 @@
                                    data-toggle="lightbox" data-gallery="photos-before" data-size="fullscreen">
                                     <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
-                                @if(hasRole('admin,manager'))
+                                @if(hasPermission('reclamations.update'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"
                                        onclick="customConfirm('Удалить фото?', function () { $('#photo-{{ $photo->id }}').submit(); }, 'Подтверждение удаления')"
                                        title="Удалить"></i>
@@ -637,7 +637,7 @@
                 <div class="photo_after">
                     <a href="#photos_after" data-bs-toggle="collapse">Фотографии после устранения
                         ({{ $reclamation->photos_after->count() }})</a>
-                    @if(hasRole('admin,manager,brigadier,warehouse_head'))
+                    @if(hasPermission('reclamations.act.upload'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-photo-after').trigger('click');"><i
                                     class="bi bi-plus-circle-fill"></i> Загрузить
                         </button>
@@ -649,7 +649,7 @@
                         </a>
                     @endif
 
-                    @if(hasRole('admin,manager,brigadier,warehouse_head'))
+                    @if(hasPermission('reclamations.act.upload'))
                         <form action="{{ route('reclamations.upload-photo-after', $reclamation) }}"
                               enctype="multipart/form-data" method="post" class="visually-hidden">
                             @csrf
@@ -665,7 +665,7 @@
                                    data-toggle="lightbox" data-gallery="photos-after" data-size="fullscreen">
                                     <img class="img-thumbnail" data-src="{{ $photo->thumbnail_link }}" alt="">
                                 </a>
-                                @if(hasRole('admin,manager'))
+                                @if(hasPermission('reclamations.update'))
                                     <i class="bi bi-x-circle-fill fs-6 text-danger cursor-pointer rm-but"
                                        onclick="customConfirm('Удалить фото?', function () { $('#photo-{{ $photo->id }}').submit(); }, 'Подтверждение удаления')"
                                        title="Удалить"></i>
@@ -696,7 +696,7 @@
         </div>
     </div>
 
-    @if(hasRole('admin'))
+    @if(hasPermission('schedule.create'))
         <!-- Модальное окно графика -->
         <div class="modal fade" id="copySchedule" tabindex="-1" aria-labelledby="copyScheduleLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
@@ -729,7 +729,7 @@
 
 @push('scripts')
     <script type="module">
-        @if(hasRole('admin'))
+        @if(hasPermission('schedule.create'))
             $('#createScheduleButton').on('click', function () {
                 let myModalSchedule = new bootstrap.Modal(document.getElementById("copySchedule"), {});
                 myModalSchedule.show();

+ 2 - 2
resources/views/reclamations/index.blade.php

@@ -7,7 +7,7 @@
             <h3>Рекламации</h3>
         </div>
         <div class="col-auto ms-md-auto page-header-actions">
-            @if(hasRole('admin,manager'))
+            @if(hasPermission('reclamations.export'))
                 <button type="button" class="btn btn-sm btn-primary page-action-btn" data-bs-toggle="modal" data-bs-target="#exportReclamationsModal" aria-label="Экспорт">
                     <i class="bi bi-download page-action-btn__icon"></i>
                     <span class="page-action-btn__label">Экспорт</span>
@@ -17,7 +17,7 @@
         </div>
     </div>
 
-    @if(hasRole('admin,manager'))
+    @if(hasPermission('reclamations.export'))
         <div class="modal fade" id="exportReclamationsModal" tabindex="-1" aria-labelledby="exportReclamationsModalLabel" aria-hidden="true">
             <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
                 <div class="modal-content">

+ 9 - 9
resources/views/schedule/index.blade.php

@@ -56,7 +56,7 @@
                                 <i class="bi bi-arrow-right"></i>
                             </button>
                         </div>
-                        @if(hasRole('admin'))
+                        @if(hasPermission('schedule.update'))
                         <div class="p-2 ms-md-3">
                             <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#exportScheduleModal">Экспорт</button>
                         </div>
@@ -81,12 +81,12 @@
                 <th>Артикулы МАФ</th>
                 <th>Кол-во позиций</th>
                 <th>Бригадир</th>
-                @if(hasRole('admin'))
+                @if(hasPermission('schedule.update'))
                     <th>Статус площадки/рекламации</th>
                     <th>Комментарий</th>
                 @endif
                 <th>Примечание</th>
-                @if(hasRole('admin'))
+                @if(hasPermission('schedule.update'))
                     <th></th>
                 @endif
             </tr>
@@ -99,7 +99,7 @@
                             @if($loop->first)
                                 <td rowspan="{{ count($schs) }}"
                                     class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
-                                    @if(hasRole('admin'))
+                                    @if(hasPermission('schedule.update'))
                                         <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
                                            title="Новая запись" data-schedule-date="{{ $dow }}"></i>
                                     @endif
@@ -143,7 +143,7 @@
                                 class="align-middle  mafs-count-{{ $schedule->id }}">{{ $schedule->mafs_count }}</td>
                             <td style="background: {{ $schedule->brigadier->color }}"
                                 class="align-middle">{{ $schedule->brigadier->name }}</td>
-                            @if(hasRole('admin'))
+                            @if(hasPermission('schedule.update'))
                                 <td style="background: {{ $schedule->brigadier->color }}"
                                     class="align-middle">
                                     @php
@@ -156,7 +156,7 @@
                             @endif
                             <td style="background: {{ $schedule->brigadier->color }}"
                                 class="align-middle comment-{{ $schedule->id }}">{{ $schedule->comment }}</td>
-                            @if(hasRole('admin'))
+                            @if(hasPermission('schedule.update'))
                                 <td style="background: {{ $schedule->brigadier->color }}" class="align-middle">
                                     <i class="bi bi-pencil p-1 m-1 cursor-pointer text-primary editSchedule"
                                        data-schedule-date="{{ $schedule->installation_date }}"
@@ -182,7 +182,7 @@
                     <tr class="schedule-week-day-end">
                         <td rowspan="1"
                             class="vertical schedule-week-day-cell">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
-                            @if(hasRole('admin'))
+                            @if(hasPermission('schedule.update'))
                                 <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
                                    title="Новая запись" data-schedule-date="{{ $dow }}"></i>
                             @endif
@@ -276,7 +276,7 @@
             @endif
         @endif
 
-        @if(hasRole('admin'))
+        @if(hasPermission('schedule.update'))
             <!-- Модальное окно экспорта графика -->
             <div class="modal fade" id="exportScheduleModal" tabindex="-1" aria-labelledby="exportScheduleModalLabel" aria-hidden="true">
                 <div class="modal-dialog modal-sm">
@@ -358,7 +358,7 @@
             document.location = '{{ route('schedule.index') }}?tab=week&year=' + year + '&week=' + week;
         });
 
-        @if(hasRole('admin'))
+        @if(hasPermission('schedule.update'))
             $('.editSchedule').on('click', function () {
                 let scheduleId = $(this).attr('data-schedule-id');
                 let scheduleDate = $(this).attr('data-schedule-date');

+ 8 - 8
resources/views/spare_part_orders/edit.blade.php

@@ -100,11 +100,11 @@
                     <button type="submit" class="btn btn-sm btn-success">Сохранить</button>
                     <a href="{{ $back_url ?? route('spare_part_orders.index') }}" class="btn btn-sm btn-secondary">Назад</a>
 
-                    @if($spare_part_order && $spare_part_order->status === 'ordered' && hasRole('admin,manager'))
+                    @if($spare_part_order && $spare_part_order->status === 'ordered' && hasPermission('spare_part_orders.update'))
                         <button type="submit" class="btn btn-sm btn-info" form="set-in-stock-form">Поступило на склад</button>
                     @endif
 
-                    @if($spare_part_order && hasRole('admin'))
+                    @if($spare_part_order && hasPermission('spare_part_orders.delete'))
                         <button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
                             Удалить
                         </button>
@@ -154,7 +154,7 @@
                                         </td>
                                         <td>{{ $reservation->created_at->format('d.m.Y H:i') }}</td>
                                         <td>
-                                            @if(hasRole('admin,manager'))
+                                            @if(hasPermission('spare_part_orders.update'))
                                                 <button type="button"
                                                         class="btn btn-sm btn-outline-primary"
                                                         title="Изменить заказ резерва"
@@ -233,11 +233,11 @@
                 {{-- История движений --}}
                 <h3>История движений</h3>
 
-                @if($spare_part_order->status === 'in_stock' && $spare_part_order->free_qty > 0 && hasRole('admin,manager'))
+                @if($spare_part_order->status === 'in_stock' && $spare_part_order->free_qty > 0 && hasPermission('spare_part_orders.update'))
                     <button type="button" class="btn btn-sm btn-warning mb-3" data-bs-toggle="modal" data-bs-target="#shipModal">
                         Отгрузить
                     </button>
-                    @if(hasRole('admin'))
+                    @if(hasPermission('spare_part_orders.delete'))
                         <button type="button" class="btn btn-sm btn-secondary mb-3" data-bs-toggle="modal" data-bs-target="#correctModal">
                             Коррекция
                         </button>
@@ -282,13 +282,13 @@
     </div>
 </div>
 
-@if($spare_part_order && $spare_part_order->status === 'ordered' && hasRole('admin,manager'))
+@if($spare_part_order && $spare_part_order->status === 'ordered' && hasPermission('spare_part_orders.update'))
     <form id="set-in-stock-form" action="{{ route('spare_part_orders.set_in_stock', ['sparePartOrder' => $spare_part_order, 'nav' => $nav ?? null]) }}" method="POST" class="d-none">
         @csrf
     </form>
 @endif
 
-@if($spare_part_order && hasRole('admin,manager'))
+@if($spare_part_order && hasPermission('spare_part_orders.update'))
     {{-- Модальное окно отгрузки --}}
     <div class="modal fade" id="shipModal" tabindex="-1" aria-labelledby="shipModalLabel" aria-hidden="true">
         <div class="modal-dialog">
@@ -329,7 +329,7 @@
     </div>
 @endif
 
-@if($spare_part_order && hasRole('admin'))
+@if($spare_part_order && hasPermission('spare_part_orders.delete'))
     {{-- Модальное окно коррекции --}}
     <div class="modal fade" id="correctModal" tabindex="-1" aria-labelledby="correctModalLabel" aria-hidden="true">
         <div class="modal-dialog">

+ 21 - 21
resources/views/spare_parts/edit.blade.php

@@ -9,7 +9,7 @@
                     <h3>{{ $spare_part ? 'Редактирование запчасти' : 'Создание запчасти' }}</h3>
                 </div>
                 <div class="col-md-6 text-end">
-                    @if($spare_part && hasRole('admin'))
+                    @if($spare_part && hasPermission('spare_parts.update'))
                         <button class="btn btn-sm text-success" onclick="$('#upl-image').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить изображение</button>
 
                         <form action="{{ route('spare_parts.upload_image', ['sparePart' => $spare_part, 'nav' => $nav ?? null]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
@@ -45,7 +45,7 @@
                                    id="article"
                                    name="article"
                                    value="{{ old('article', $spare_part->article ?? '') }}"
-                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}
+                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}
                                    required>
                         </div>
 
@@ -56,7 +56,7 @@
                                    id="used_in_maf"
                                    name="used_in_maf"
                                    value="{{ old('used_in_maf', $spare_part->used_in_maf ?? '') }}"
-                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -65,13 +65,13 @@
                                       id="note"
                                       name="note"
                                       rows="3"
-                                      {{ hasRole('admin,manager') ? '' : 'readonly' }}>{{ old('note', $spare_part->note ?? '') }}</textarea>
+                                      {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>{{ old('note', $spare_part->note ?? '') }}</textarea>
                         </div>
                     </div>
 
                     {{-- Правая колонка --}}
                     <div class="col-md-6">
-                        @if(hasRole('admin'))
+                        @if(hasPermission('spare_parts.update'))
                             <div class="mb-3">
                                 <label for="purchase_price" class="form-label">Цена закупки (руб.)</label>
                                 <input type="number"
@@ -91,7 +91,7 @@
                                    name="customer_price"
                                    step="0.01"
                                    value="{{ old('customer_price', $spare_part->customer_price ?? '') }}"
-                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -102,7 +102,7 @@
                                    name="expertise_price"
                                    step="0.01"
                                    value="{{ old('expertise_price', $spare_part->expertise_price ?? '') }}"
-                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -115,7 +115,7 @@
                                            name="tsn_number"
                                            value="{{ old('tsn_number', $spare_part->tsn_number ?? '') }}"
                                            autocomplete="off"
-                                           {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                           {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                     <div class="autocomplete-dropdown" id="tsn_number_dropdown"></div>
                                 </div>
                                 <div class="col-md-7">
@@ -124,7 +124,7 @@
                                            id="tsn_number_description"
                                            name="tsn_number_description"
                                            placeholder="Расшифровка"
-                                           {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                           {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                 </div>
                             </div>
                             <div class="form-text" id="tsn_number_hint"></div>
@@ -145,7 +145,7 @@
                                                    value="{{ $code }}"
                                                    autocomplete="off"
                                                    placeholder="Код"
-                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                             <div class="autocomplete-dropdown pricing-code-dropdown"></div>
                                         </div>
                                         <div class="col-md-5">
@@ -153,10 +153,10 @@
                                                    class="form-control pricing-code-description-input"
                                                    name="pricing_codes_descriptions[]"
                                                    placeholder="Расшифровка"
-                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                         </div>
                                         <div class="col-md-2">
-                                            @if(hasRole('admin,manager'))
+                                            @if(hasPermission('spare_parts.update'))
                                                 <button type="button" class="btn btn-outline-danger btn-remove-pricing-code" title="Удалить">
                                                     <i class="bi bi-trash"></i>
                                                 </button>
@@ -173,7 +173,7 @@
                                                    value=""
                                                    autocomplete="off"
                                                    placeholder="Код"
-                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                             <div class="autocomplete-dropdown pricing-code-dropdown"></div>
                                         </div>
                                         <div class="col-md-5">
@@ -181,10 +181,10 @@
                                                    class="form-control pricing-code-description-input"
                                                    name="pricing_codes_descriptions[]"
                                                    placeholder="Расшифровка"
-                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                                         </div>
                                         <div class="col-md-2">
-                                            @if(hasRole('admin,manager'))
+                                            @if(hasPermission('spare_parts.update'))
                                                 <button type="button" class="btn btn-outline-danger btn-remove-pricing-code" title="Удалить">
                                                     <i class="bi bi-trash"></i>
                                                 </button>
@@ -194,7 +194,7 @@
                                     </div>
                                 @endforelse
                             </div>
-                            @if(hasRole('admin,manager'))
+                            @if(hasPermission('spare_parts.update'))
                                 <button type="button" class="btn btn-sm btn-outline-primary" id="btn_add_pricing_code">
                                     <i class="bi bi-plus-circle"></i> Добавить код
                                 </button>
@@ -208,19 +208,19 @@
                                    id="min_stock"
                                    name="min_stock"
                                    value="{{ old('min_stock', $spare_part->min_stock ?? 0) }}"
-                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
+                                   {{ hasPermission('spare_parts.update') ? '' : 'readonly' }}>
                         </div>
                     </div>
                 </div>
 
                 <div class="row">
                     <div class="col-12">
-                        @if(hasRole('admin,manager'))
+                        @if(hasPermission('spare_parts.update'))
                             <button type="submit" class="btn btn-sm btn-success">Сохранить</button>
                         @endif
                         <a href="{{ $back_url ?? route('spare_parts.index') }}" class="btn btn-sm btn-secondary">Назад</a>
 
-                        @if($spare_part && hasRole('admin'))
+                        @if($spare_part && hasPermission('spare_parts.update'))
                             <button type="button" class="btn btn-sm btn-danger float-end" data-bs-toggle="modal" data-bs-target="#deleteModal">
                                 Удалить
                             </button>
@@ -519,7 +519,7 @@
     </div>
 </div>
 
-@if($spare_part && hasRole('admin'))
+@if($spare_part && hasPermission('spare_parts.update'))
     {{-- Модальное окно удаления --}}
     <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteSparePartModalLabel" aria-hidden="true">
         <div class="modal-dialog">
@@ -556,7 +556,7 @@ function waitForJQuery(callback) {
 
 waitForJQuery(function() {
     $(document).ready(function() {
-    @if(hasRole('admin'))
+    @if(hasPermission('spare_parts.update'))
         //console.log('Autocomplete init started');
 
         // Общая функция для инициализации автокомплита

+ 7 - 7
resources/views/spare_parts/index.blade.php

@@ -26,7 +26,7 @@
                         Контроль наличия
                     </a>
                 </li>
-                @if(hasRole('admin'))
+                @if(hasAnyPermission(['spare_parts.create', 'spare_parts.import', 'spare_parts.export', 'spare_parts.delete']))
                 <li class="nav-item">
                     <a class="nav-link" href="{{ route('pricing_codes.index', session('gp_pricing_codes', [])) }}">
                         Справочник расшифровок
@@ -44,10 +44,10 @@
             @if(($tab ?? 'catalog') === 'catalog')
                 {{-- Кнопки управления --}}
                 <div class="mb-3">
-                    @if(hasRole('admin,manager'))
+                    @if(hasAnyPermission(['spare_parts.update', 'spare_part_orders.create', 'spare_part_orders.ship']))
                         <a href="{{ route('spare_parts.create') }}" class="btn btn-sm btn-primary">Добавить запчасть</a>
                     @endif
-                    @if(hasRole('admin'))
+                    @if(hasAnyPermission(['spare_parts.create', 'spare_parts.import', 'spare_parts.export', 'spare_parts.delete']))
                         <form action="{{ route('spare_parts.export') }}" method="POST" class="d-inline">
                             @csrf
                             <button type="submit" class="btn btn-sm btn-success">Экспорт</button>
@@ -80,7 +80,7 @@
                 {{-- Таблица заказов --}}
                 @if(isset($spare_part_orders))
                     <div class="mb-3">
-                        @if(hasRole('admin,manager'))
+                        @if(hasAnyPermission(['spare_parts.update', 'spare_part_orders.create', 'spare_part_orders.ship']))
                             <a href="{{ route('spare_part_orders.create') }}" class="btn btn-sm btn-primary">Создать заказ</a>
                             <button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#setOrderInStockModal">
                                 Отгрузить весь заказ
@@ -279,7 +279,7 @@
 </div>
 
 {{-- Модальное окно импорта --}}
-@if(hasRole('admin'))
+@if(hasAnyPermission(['spare_parts.create', 'spare_parts.import', 'spare_parts.export', 'spare_parts.delete']))
 <div class="modal fade" id="importModal" tabindex="-1" aria-labelledby="importSparePartsModalLabel" aria-hidden="true">
     <div class="modal-dialog">
         <div class="modal-content">
@@ -310,7 +310,7 @@
 </div>
 @endif
 
-@if(hasRole('admin,manager') && ($tab ?? '') === 'orders')
+@if(hasAnyPermission(['spare_parts.update', 'spare_part_orders.create', 'spare_part_orders.ship']) && ($tab ?? '') === 'orders')
 <div class="modal fade" id="setOrderInStockModal" tabindex="-1" aria-labelledby="setOrderInStockModalLabel" aria-hidden="true">
     <div class="modal-dialog">
         <div class="modal-content">
@@ -369,7 +369,7 @@
             window.location.href = url;
         });
 
-        @if($errors->has('bulk_order_number') && hasRole('admin,manager') && ($tab ?? '') === 'orders')
+        @if($errors->has('bulk_order_number') && hasAnyPermission(['spare_parts.update', 'spare_part_orders.create', 'spare_part_orders.ship']) && ($tab ?? '') === 'orders')
             const setOrderInStockModalElement = document.getElementById('setOrderInStockModal');
             if (setOrderInStockModalElement) {
                 const setOrderInStockModal = new bootstrap.Modal(setOrderInStockModalElement);

+ 48 - 44
resources/views/users/edit.blade.php

@@ -18,9 +18,9 @@
                     <li class="nav-item" role="presentation">
                         <button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications-pane" type="button" role="tab">Уведомления</button>
                     </li>
-{{--                    <li class="nav-item" role="presentation">--}}
-{{--                        <button class="nav-link" id="permissions-tab" data-bs-toggle="tab" data-bs-target="#permissions-pane" type="button" role="tab">Права</button>--}}
-{{--                    </li>--}}
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="permissions-tab" data-bs-toggle="tab" data-bs-target="#permissions-pane" type="button" role="tab">Права</button>
+                    </li>
                 </ul>
 
                 <div class="tab-content">
@@ -70,45 +70,49 @@
                         ])
                     </div>
 
-{{--                    <div class="tab-pane fade" id="permissions-pane" role="tabpanel" aria-labelledby="permissions-tab">--}}
-{{--                        @if(($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN)--}}
-{{--                            <div class="alert alert-warning py-2">--}}
-{{--                                Администратору всегда доступны все права. Пользовательские deny и снятие прав не применяются.--}}
-{{--                            </div>--}}
-{{--                        @else--}}
-{{--                            <div class="mb-2 text-muted small">--}}
-{{--                                Значение "По роли" оставляет право как в выбранной роли. Allow или deny здесь переопределяют роль только для этого пользователя.--}}
-{{--                            </div>--}}
-{{--                        @endif--}}
-{{-- --}}
-{{--                        @include('admin.roles.partials.permissions-table', [--}}
-{{--                            'permissionGroups' => $permissionGroups ?? collect(),--}}
-{{--                            'permissionEffects' => $permissionEffects ?? [],--}}
-{{--                            'inputName' => 'permission_effects',--}}
-{{--                            'adminLocked' => ($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN,--}}
-{{--                            'inheritLabel' => 'По роли',--}}
-{{--                        ])--}}
-{{--                    </div>--}}
+                    <div class="tab-pane fade" id="permissions-pane" role="tabpanel" aria-labelledby="permissions-tab">
+                        @php($isSystemAdmin = $user?->roleModel?->is_system && $user?->roleModel?->slug === \App\Models\Role::ADMIN)
+
+                        @if($isSystemAdmin)
+                            <div class="alert alert-warning py-2">
+                                Администратору всегда доступны все права. Пользовательские overrides для системного администратора не применяются.
+                            </div>
+                        @else
+                            <div class="mb-2 text-muted small">
+                                Значение "По роли" оставляет право как в выбранной роли. Allow или deny переопределяют право только для этого пользователя.
+                            </div>
+                        @endif
+
+                        @include('admin.roles.partials.permissions-table', [
+                            'permissionGroups' => $permissionGroups ?? collect(),
+                            'permissionEffects' => $permissionEffects ?? [],
+                            'inheritedPermissionEffects' => $rolePermissionEffects ?? [],
+                            'inputName' => 'permission_effects',
+                            'adminLocked' => $isSystemAdmin,
+                            'inheritLabel' => 'По роли',
+                        ])
+                    </div>
+
                 </div>
 
-{{--                @if($user)--}}
-{{--                    <div class="mt-3 border-top pt-3">--}}
-{{--                        <div class="row">--}}
-{{--                            <label class="col-form-label small col-md-4 text-md-end">Создать роль из прав</label>--}}
-{{--                            <div class="col-md-8">--}}
-{{--                                <div class="input-group input-group-sm mb-2">--}}
-{{--                                    <input type="text" name="name" class="form-control" form="create-role-from-user"--}}
-{{--                                           placeholder="Название новой роли">--}}
-{{--                                    <input type="text" name="slug" class="form-control" form="create-role-from-user"--}}
-{{--                                           placeholder="code">--}}
-{{--                                    <button type="submit" class="btn btn-outline-primary" form="create-role-from-user">--}}
-{{--                                        Создать роль--}}
-{{--                                    </button>--}}
-{{--                                </div>--}}
-{{--                            </div>--}}
-{{--                        </div>--}}
-{{--                    </div>--}}
-{{--                @endif--}}
+                @if($user)
+                    <div class="mt-3 border-top pt-3">
+                        <div class="row">
+                            <label class="col-form-label small col-md-4 text-md-end">Создать роль из прав</label>
+                            <div class="col-md-8">
+                                <div class="input-group input-group-sm mb-2">
+                                    <input type="text" name="name" class="form-control" form="create-role-from-user"
+                                           placeholder="Название новой роли">
+                                    <input type="text" name="slug" class="form-control" form="create-role-from-user"
+                                           placeholder="code">
+                                    <button type="submit" class="btn btn-outline-primary" form="create-role-from-user">
+                                        Создать роль
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                @endif
 
                 @if($user && !is_null($user->deleted_at))
                     <div class="col-12 text-center">
@@ -135,10 +139,10 @@
                 <form action="{{ route('user.impersonate', $user->id) }}" method="post" class="d-none" id="impersonate-user">
                     @csrf
                 </form>
-{{--                <form action="{{ route('admin.roles.store-from-user', $user) }}" method="post" class="d-none" id="create-role-from-user">--}}
-{{--                    @csrf--}}
-{{--                    <input type="hidden" name="description" value="Создана из прав пользователя {{ $user->name }}">--}}
-{{--                </form>--}}
+                <form action="{{ route('admin.roles.store-from-user', $user) }}" method="post" class="d-none" id="create-role-from-user">
+                    @csrf
+                    <input type="hidden" name="description" value="Создана из прав пользователя {{ $user->name }}">
+                </form>
             @endif
         </div>
     </div>

+ 29 - 40
routes/web.php

@@ -28,8 +28,6 @@ use App\Http\Controllers\SparePartOrderController;
 use App\Http\Controllers\SparePartReservationController;
 use App\Http\Controllers\UserController;
 use App\Http\Controllers\UserNotificationController;
-use App\Models\PricingCode;
-use App\Models\Role;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Route;
@@ -69,7 +67,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     })->name('set-year');
 
     // admin routes
-    Route::middleware('role:' . Role::ADMIN)->prefix('admin')->group(function (){
+    Route::prefix('admin')->group(function (){
         Route::prefix('users')->group(function (){
             Route::get('', [UserController::class, 'index'])->name('user.index');
             Route::get('create', [UserController::class, 'create'])->name('user.create');
@@ -153,7 +151,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
     Route::get('get-filters', [FilterController::class, 'getFilters'])->name('getFilters');
 
-    Route::middleware('role:admin,manager')->group(function () {
+    Route::group([], function () {
         Route::prefix('responsibles')->group(function (){
             Route::get('', [ResponsibleController::class, 'index'])->name('responsible.index');
             Route::get('{responsible}', [ResponsibleController::class, 'show'])->name('responsible.show');
@@ -175,7 +173,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::delete('contract/{contract}', [ContractController::class, 'delete'])->name('contract.delete');
 
         // contractors
-        Route::prefix('contractors')->name('contractors.')->middleware('role:admin,' . Role::ASSISTANT_HEAD)->group(function () {
+        Route::prefix('contractors')->name('contractors.')->group(function () {
             Route::get('', [ContractorController::class, 'index'])->name('index');
             Route::get('create', [ContractorController::class, 'create'])->name('create');
             Route::post('', [ContractorController::class, 'store'])->name('store');
@@ -191,7 +189,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
         Route::post('order/store', [OrderController::class, 'store'])->name('order.store');
         Route::post('order/update', [OrderController::class, 'store'])->name('order.update');
-        Route::delete('order/delete/{order}', [OrderController::class, 'destroy'])->name('order.destroy')->middleware('role:' . Role::ADMIN);
+        Route::delete('order/delete/{order}', [OrderController::class, 'destroy'])->name('order.destroy');
 
         Route::post('order/{order}/upload-document', [OrderController::class, 'uploadDocument'])->name('order.upload-document');
         Route::post('order/{order}/upload-statement', [OrderController::class, 'uploadStatement'])->name('order.upload-statement');
@@ -222,13 +220,12 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::post('reclamations/{reclamation}/update-status', [ReclamationController::class, 'updateStatus'])->name('reclamations.update-status');
         Route::post('reclamations/{reclamation}/upload-document', [ReclamationController::class, 'uploadDocument'])->name('reclamations.upload-document');
         Route::delete('reclamations/delete-document/{reclamation}/{file}', [ReclamationController::class, 'deleteDocument'])
-            ->name('reclamations.delete-document')
-            ->middleware('role:admin,manager');
+            ->name('reclamations.delete-document');
         Route::delete('reclamations/delete-photo-before/{reclamation}/{file}', [ReclamationController::class, 'deletePhotoBefore'])->name('reclamations.delete-photo-before');
         Route::delete('reclamations/delete-photo-after/{reclamation}/{file}', [ReclamationController::class, 'deletePhotoAfter'])->name('reclamations.delete-photo-after');
         Route::post('reclamations/{reclamation}/update-details', [ReclamationController::class, 'updateDetails'])->name('reclamations.update-details');
         Route::post('reclamations/{reclamation}/update-spare-parts', [ReclamationController::class, 'updateSpareParts'])->name('reclamations.update-spare-parts');
-        Route::delete('reclamations/{reclamation}', [ReclamationController::class, 'delete'])->name('reclamations.delete')->middleware('role:' . Role::ADMIN);
+        Route::delete('reclamations/{reclamation}', [ReclamationController::class, 'delete'])->name('reclamations.delete');
 
         Route::get('reports', [ReportController::class, 'index'])->name('reports.index');
 
@@ -236,8 +233,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     Route::post('mafs-registry', [ProductSKUController::class, 'exportMafRegistry'])
-        ->name('mafs.registry')
-        ->middleware('role:admin,' . Role::ASSISTANT_HEAD);
+        ->name('mafs.registry');
 
 
     // orders for all
@@ -246,17 +242,15 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     Route::post('order/{order}/upload-photo', [OrderController::class, 'uploadPhoto'])->name('order.upload-photo');
     Route::post('order/{order}/chat-messages', [ChatMessageController::class, 'storeForOrder'])->name('order.chat-messages.store');
     Route::delete('order/{order}/chat-messages/{chatMessage}', [ChatMessageController::class, 'destroyForOrder'])
-        ->name('order.chat-messages.delete')
-        ->middleware('role:' . Role::ADMIN);
+        ->name('order.chat-messages.delete');
     Route::get('order/generate-photos-pack/{order}', [OrderController::class, 'generatePhotosPack'])->name('order.generate-photos-pack');
     Route::get('order/download-tech-docs/{order}', [OrderController::class, 'downloadTechDocs'])->name('order.download-tech-docs');
     Route::post('order/{order}/contractor-specification', [OrderController::class, 'createContractorSpecification'])
-        ->name('order.contractor-specification')
-        ->middleware('role:admin,' . Role::ASSISTANT_HEAD);
+        ->name('order.contractor-specification');
     Route::post('order/{order}/add-statement-to-mafs', [OrderController::class, 'addStatementToMafs'])
         ->name('order.add-statement-to-mafs');
 
-    Route::middleware('role:' . Role::ADMIN)->group(function () {
+    Route::group([], function () {
         Route::get('catalog/create', [ProductController::class, 'create'])->name('catalog.create');
         Route::post('catalog', [ProductController::class, 'store'])->name('catalog.store');
         Route::post('catalog/{product}', [ProductController::class, 'update'])->name('catalog.update');
@@ -303,8 +297,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     Route::post('reclamations/export', [ReclamationController::class, 'export'])
-        ->name('reclamations.export')
-        ->middleware('role:admin,manager');
+        ->name('reclamations.export');
 
 
 
@@ -315,23 +308,19 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     Route::get('reclamations/show/{reclamation}', [ReclamationController::class, 'show'])->name('reclamations.show');
     Route::post('reclamations/{reclamation}/chat-messages', [ChatMessageController::class, 'storeForReclamation'])->name('reclamations.chat-messages.store');
     Route::delete('reclamations/{reclamation}/chat-messages/{chatMessage}', [ChatMessageController::class, 'destroyForReclamation'])
-        ->name('reclamations.chat-messages.delete')
-        ->middleware('role:' . Role::ADMIN);
+        ->name('reclamations.chat-messages.delete');
 
 
     Route::post('reclamations/{reclamation}/upload-photo-before', [ReclamationController::class, 'uploadPhotoBefore'])->name('reclamations.upload-photo-before');
     Route::post('reclamations/{reclamation}/upload-photo-after', [ReclamationController::class, 'uploadPhotoAfter'])->name('reclamations.upload-photo-after');
     Route::post('reclamations/{reclamation}/upload-act', [ReclamationController::class, 'uploadAct'])
-        ->name('reclamations.upload-act')
-        ->middleware('role:admin,manager,brigadier,' . Role::WAREHOUSE_HEAD);
+        ->name('reclamations.upload-act');
     Route::delete('reclamations/delete-act/{reclamation}/{file}', [ReclamationController::class, 'deleteAct'])
-        ->name('reclamations.delete-act')
-        ->middleware('role:admin,manager');
+        ->name('reclamations.delete-act');
 
     Route::get('schedule', [ScheduleController::class, 'index'])->name('schedule.index');
     Route::post('order/export', [OrderController::class, 'export'])
-        ->name('order.export')
-        ->middleware('role:admin,manager');
+        ->name('order.export');
 
 
     // ajax search products
@@ -344,36 +333,36 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     Route::get('areas/{district_id?}', [AreaController::class, 'ajaxGetAreasByDistrict'])->name('area.ajax-get-areas-by-district');
 
     // Каталог запчастей
-    Route::prefix('spare-parts')->name('spare_parts.')->middleware('role:admin,manager')->group(function () {
+    Route::prefix('spare-parts')->name('spare_parts.')->group(function () {
         Route::get('/', [SparePartController::class, 'index'])->name('index');
         Route::get('/help', [SparePartController::class, 'help'])->name('help');
         Route::get('/search', [SparePartController::class, 'search'])->name('search');
-        Route::get('/create', [SparePartController::class, 'create'])->name('create')->middleware('role:admin');
+        Route::get('/create', [SparePartController::class, 'create'])->name('create');
         Route::get('/{sparePart}', [SparePartController::class, 'show'])->name('show');
-        Route::post('/', [SparePartController::class, 'store'])->name('store')->middleware('role:admin');
-        Route::put('/{sparePart}', [SparePartController::class, 'update'])->name('update')->middleware('role:admin');
-        Route::delete('/{sparePart}', [SparePartController::class, 'destroy'])->name('destroy')->middleware('role:admin');
-        Route::post('/export', [SparePartController::class, 'export'])->name('export')->middleware('role:admin');
-        Route::post('/import', [SparePartController::class, 'import'])->name('import')->middleware('role:admin');
-        Route::post('/{sparePart}/upload-image', [SparePartController::class, 'uploadImage'])->name('upload_image')->middleware('role:admin');
+        Route::post('/', [SparePartController::class, 'store'])->name('store');
+        Route::put('/{sparePart}', [SparePartController::class, 'update'])->name('update');
+        Route::delete('/{sparePart}', [SparePartController::class, 'destroy'])->name('destroy');
+        Route::post('/export', [SparePartController::class, 'export'])->name('export');
+        Route::post('/import', [SparePartController::class, 'import'])->name('import');
+        Route::post('/{sparePart}/upload-image', [SparePartController::class, 'uploadImage'])->name('upload_image');
     });
 
     // Заказы деталей
-    Route::prefix('spare-part-orders')->name('spare_part_orders.')->middleware('role:admin,manager')->group(function () {
+    Route::prefix('spare-part-orders')->name('spare_part_orders.')->group(function () {
         Route::get('/', [SparePartOrderController::class, 'index'])->name('index');
         Route::get('/create', [SparePartOrderController::class, 'create'])->name('create');
         Route::get('/{sparePartOrder}', [SparePartOrderController::class, 'show'])->name('show');
         Route::post('/', [SparePartOrderController::class, 'store'])->name('store');
         Route::put('/{sparePartOrder}', [SparePartOrderController::class, 'update'])->name('update');
-        Route::delete('/{sparePartOrder}', [SparePartOrderController::class, 'destroy'])->name('destroy')->middleware('role:admin');
+        Route::delete('/{sparePartOrder}', [SparePartOrderController::class, 'destroy'])->name('destroy');
         Route::post('/{sparePartOrder}/ship', [SparePartOrderController::class, 'ship'])->name('ship');
         Route::post('/{sparePartOrder}/set-in-stock', [SparePartOrderController::class, 'setInStock'])->name('set_in_stock');
         Route::post('/set-order-in-stock', [SparePartOrderController::class, 'setOrderInStock'])->name('set_order_in_stock');
-        Route::post('/{sparePartOrder}/correct', [SparePartOrderController::class, 'correct'])->name('correct')->middleware('role:admin');
+        Route::post('/{sparePartOrder}/correct', [SparePartOrderController::class, 'correct'])->name('correct');
     });
 
     // Резервы запчастей
-    Route::prefix('spare-part-reservations')->name('spare_part_reservations.')->middleware('role:admin,manager')->group(function () {
+    Route::prefix('spare-part-reservations')->name('spare_part_reservations.')->group(function () {
         Route::get('/reclamation/{reclamationId}', [SparePartReservationController::class, 'forReclamation'])->name('for_reclamation');
         Route::get('/shortages/{reclamationId}', [SparePartReservationController::class, 'shortagesForReclamation'])->name('shortages_for_reclamation');
         Route::post('/{reservation}/cancel', [SparePartReservationController::class, 'cancel'])->name('cancel');
@@ -384,14 +373,14 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     // Контроль наличия
-    Route::get('/spare-part-inventory', [SparePartInventoryController::class, 'index'])->name('spare_part_inventory.index')->middleware('role:admin,manager');
+    Route::get('/spare-part-inventory', [SparePartInventoryController::class, 'index'])->name('spare_part_inventory.index');
 
     // API для получения расшифровки кодов (без ограничения по роли, должны быть ПЕРЕД группой с prefix)
     Route::get('/pricing-codes/get-description', [PricingCodeController::class, 'getDescription'])->name('pricing_codes.get_description');
     Route::get('/pricing-codes/search', [PricingCodeController::class, 'search'])->name('pricing_codes.search');
 
     // Справочник расшифровок (admin only)
-    Route::prefix('pricing-codes')->name('pricing_codes.')->middleware('role:admin')->group(function () {
+    Route::prefix('pricing-codes')->name('pricing_codes.')->group(function () {
         Route::get('/', [PricingCodeController::class, 'index'])->name('index');
         Route::post('/', [PricingCodeController::class, 'store'])->name('store');
         Route::put('/{pricingCode}', [PricingCodeController::class, 'update'])->name('update');

+ 2 - 0
tests/Feature/AdminRoleControllerTest.php

@@ -13,6 +13,8 @@ class AdminRoleControllerTest extends TestCase
 {
     use RefreshDatabase;
 
+    protected $seed = true;
+
     private User $adminUser;
 
     protected function setUp(): void

+ 2 - 0
tests/Feature/CatalogFieldAccessTest.php

@@ -14,6 +14,8 @@ class CatalogFieldAccessTest extends TestCase
 {
     use RefreshDatabase;
 
+    protected $seed = true;
+
     protected function setUp(): void
     {
         parent::setUp();

+ 17 - 1
tests/Feature/OrderControllerTest.php

@@ -11,6 +11,7 @@ use App\Models\MafOrder;
 use App\Models\ObjectType;
 use App\Models\Order;
 use App\Models\OrderStatus;
+use App\Models\Permission;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Role;
@@ -527,6 +528,21 @@ class OrderControllerTest extends TestCase
 
     public function test_order_details_hide_inline_maf_controls_without_permissions(): void
     {
+        $role = Role::query()->create([
+            'slug' => 'orders_viewer_without_maf_permissions',
+            'name' => 'Orders viewer without MAF permissions',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permissions = Permission::query()
+            ->whereIn('slug', ['orders.view', 'orders.scope.manager'])
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role->permissions()->sync($permissions);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
         $order = Order::factory()->create();
         $product = Product::factory()->create(['article' => 'MAF-NO-INLINE-001']);
         $sku = ProductSKU::factory()->create([
@@ -536,7 +552,7 @@ class OrderControllerTest extends TestCase
             'rfid' => 'RFID-READONLY',
         ]);
 
-        $response = $this->actingAs($this->managerUser)
+        $response = $this->actingAs($user)
             ->get(route('order.show', $order));
 
         $response->assertOk();

+ 40 - 0
tests/Feature/RoutePermissionMiddlewareTest.php

@@ -15,6 +15,8 @@ class RoutePermissionMiddlewareTest extends TestCase
 {
     use RefreshDatabase;
 
+    protected $seed = true;
+
     protected function setUp(): void
     {
         parent::setUp();
@@ -103,4 +105,42 @@ class RoutePermissionMiddlewareTest extends TestCase
             ])
             ->assertRedirect(route('import.index'));
     }
+
+    public function test_custom_role_with_admin_permissions_can_open_admin_routes(): void
+    {
+        $user = $this->createUserWithAllPermissions('root_admin_routes');
+
+        $this->actingAs($user)
+            ->get(route('admin.roles.index'))
+            ->assertOk()
+            ->assertSee('Роли и права');
+    }
+
+    public function test_custom_role_with_admin_permissions_sees_admin_catalog_actions(): void
+    {
+        $user = $this->createUserWithAllPermissions('root_catalog_ui');
+
+        $this->actingAs($user)
+            ->get(route('catalog.index'))
+            ->assertOk()
+            ->assertSee(route('catalog.create'), false)
+            ->assertSee('data-bs-target="#importModal"', false)
+            ->assertSee('data-bs-target="#exportModal"', false);
+    }
+
+    private function createUserWithAllPermissions(string $slug): User
+    {
+        $role = Role::query()->create([
+            'slug' => $slug,
+            'name' => $slug,
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permissions = Permission::query()
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role->permissions()->sync($permissions);
+
+        return User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+    }
 }

+ 54 - 0
tests/Feature/ScheduleControllerTest.php

@@ -6,6 +6,7 @@ use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\MafOrder;
 use App\Models\Order;
+use App\Models\Permission;
 use App\Models\Product;
 use App\Models\ProductSKU;
 use App\Models\Reclamation;
@@ -215,6 +216,59 @@ class ScheduleControllerTest extends TestCase
         $response->assertDontSee($foreignReclamation->reason);
     }
 
+    public function test_custom_role_with_brigadier_schedule_scope_uses_brigadier_visibility(): void
+    {
+        $permissionIds = Permission::query()
+            ->whereIn('slug', ['schedule.view', 'schedule.scope.brigadier'])
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role = Role::query()->create([
+            'slug' => 'custom_brigadier_scope',
+            'name' => 'Custom brigadier scope',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync($permissionIds);
+        $customUser = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+        $otherUser = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        $visibleOrder = Order::factory()->create([
+            'brigadier_id' => $customUser->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+            'object_address' => 'ул. Кастомная видимая, д. 1',
+        ]);
+        $foreignOrder = Order::factory()->create([
+            'brigadier_id' => $otherUser->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+            'object_address' => 'ул. Кастомная чужая, д. 2',
+        ]);
+
+        Schedule::factory()->create([
+            'source' => 'Площадки',
+            'order_id' => $visibleOrder->id,
+            'brigadier_id' => $customUser->id,
+            'installation_date' => '2026-03-16',
+            'object_address' => $visibleOrder->object_address,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Площадки',
+            'order_id' => $foreignOrder->id,
+            'brigadier_id' => $otherUser->id,
+            'installation_date' => '2026-03-16',
+            'object_address' => $foreignOrder->object_address,
+        ]);
+
+        $response = $this->actingAs($customUser)
+            ->get(route('schedule.index', ['year' => 2026, 'week' => 12]));
+
+        $response->assertOk();
+        $response->assertSee($visibleOrder->object_address);
+        $response->assertDontSee($foreignOrder->object_address);
+    }
+
     // ==================== Update (create manual schedule) ====================
 
     public function test_admin_can_create_manual_schedule(): void

+ 18 - 0
tests/Feature/UserControllerTest.php

@@ -128,6 +128,24 @@ class UserControllerTest extends TestCase
         $response->assertViewIs('users.edit');
     }
 
+    public function test_user_edit_shows_permission_overrides_and_inherited_role_permissions(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $user = User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $managerRole->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('user.show', $user));
+
+        $response->assertOk();
+        $response->assertSee('id="permissions-pane"', false);
+        $response->assertSee('name="permission_effects[', false);
+        $response->assertSee('orders.view');
+        $response->assertSee('По роли: разрешено');
+    }
+
     public function test_user_show_uses_nav_context_back_url(): void
     {
         $user = User::factory()->create(['role' => Role::MANAGER]);

+ 78 - 2
tests/Unit/Services/AccessServiceTest.php

@@ -14,6 +14,8 @@ class AccessServiceTest extends TestCase
 {
     use RefreshDatabase;
 
+    protected $seed = true;
+
     public function test_rbac_seeder_backfills_user_role_id(): void
     {
         $user = User::factory()->create(['role' => Role::MANAGER]);
@@ -26,7 +28,7 @@ class AccessServiceTest extends TestCase
         $this->assertSame(Role::MANAGER, $user->roleModel->slug);
     }
 
-    public function test_direct_admin_has_all_permissions(): void
+    public function test_admin_role_has_seeded_permissions(): void
     {
         $admin = User::factory()->create(['role' => Role::ADMIN]);
 
@@ -50,6 +52,41 @@ class AccessServiceTest extends TestCase
         $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.update'));
     }
 
+    public function test_seeded_roles_have_visibility_scopes(): void
+    {
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $warehouseHead = User::factory()->create(['role' => Role::WAREHOUSE_HEAD]);
+
+        $this->seed(RbacSeeder::class);
+
+        $this->assertSame('manager', $manager->refresh()->visibilityScope('orders'));
+        $this->assertSame('brigadier', $brigadier->refresh()->visibilityScope('orders'));
+        $this->assertSame('warehouse_head', $warehouseHead->refresh()->visibilityScope('orders'));
+        $this->assertSame('manager', $warehouseHead->visibilityScope('schedule'));
+    }
+
+    public function test_visibility_scope_uses_highest_priority_permission(): void
+    {
+        $this->seed(RbacSeeder::class);
+
+        $role = Role::query()->create([
+            'slug' => 'hybrid_scope',
+            'name' => 'Hybrid scope',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permissions = Permission::query()
+            ->whereIn('slug', ['orders.scope.brigadier', 'orders.scope.manager'])
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role->permissions()->sync($permissions);
+
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->assertSame('manager', app(AccessService::class)->visibilityScope($user, 'orders'));
+    }
+
     public function test_role_deny_overrides_user_allow(): void
     {
         $this->seed(RbacSeeder::class);
@@ -104,7 +141,46 @@ class AccessServiceTest extends TestCase
         $this->seed(RbacSeeder::class);
         $assistantHead->refresh();
 
-        $this->assertTrue($assistantHead->hasRole(Role::ADMIN));
         $this->assertTrue(app(AccessService::class)->can($assistantHead, 'maf_orders.delete'));
     }
+
+    public function test_custom_role_with_admin_permissions_has_same_action_access(): void
+    {
+        $this->seed(RbacSeeder::class);
+
+        $role = Role::query()->create([
+            'slug' => 'root',
+            'name' => 'Root',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permissions = Permission::query()
+            ->pluck('id')
+            ->mapWithKeys(fn (int $id): array => [$id => ['effect' => 'allow']]);
+        $role->permissions()->sync($permissions);
+
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->assertTrue(app(AccessService::class)->can($user, 'admin.roles'));
+        $this->assertTrue(app(AccessService::class)->can($user, 'catalog.delete'));
+        $this->assertTrue(app(AccessService::class)->can($user, 'orders.documents.generate'));
+        $this->assertSame('admin', app(AccessService::class)->visibilityScope($user, 'orders'));
+    }
+
+    public function test_user_permission_query_scopes_include_custom_roles(): void
+    {
+        $this->seed(RbacSeeder::class);
+
+        $role = Role::query()->create([
+            'slug' => 'root_scope',
+            'name' => 'Root scope',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $permission = Permission::query()->where('slug', 'orders.scope.admin')->firstOrFail();
+        $role->permissions()->sync([$permission->id => ['effect' => 'allow']]);
+        $user = User::factory()->create(['role' => $role->slug, 'role_id' => $role->id]);
+
+        $this->assertTrue(User::query()->withPermission('orders.scope.admin')->whereKey($user->id)->exists());
+    }
 }