feat(dashboard): add Roles CRUD with permission-based menu filtering
- Roles CRUD: List/Create/Update/Delete actions, controller, requests - Roles Vue pages: Index, Create, Edit (Options API, DashboardLayout) - SidebarNav: filter menu items by user permissions via Inertia shared data - HandleInertiaRequests: share permissions and roles arrays to frontend - HttpKernel: register dashboard.permission middleware alias - SidebarNavItem: add shield-check icon mapping
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\Roles;
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class CreateRoleAction
|
||||
{
|
||||
public function run(array $data): Role
|
||||
{
|
||||
$role = Role::create(['name' => $data['name']]);
|
||||
|
||||
if (!empty($data['permissions'])) {
|
||||
$role->syncPermissions($data['permissions']);
|
||||
}
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\Roles;
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class DeleteRoleAction
|
||||
{
|
||||
public function run(Role $role): void
|
||||
{
|
||||
$role->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\Roles;
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class ListRolesAction
|
||||
{
|
||||
public function run(): array
|
||||
{
|
||||
$roles = Role::with('permissions')->orderBy('name')->get();
|
||||
|
||||
return [
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\Actions\Roles;
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UpdateRoleAction
|
||||
{
|
||||
public function run(Role $role, array $data): Role
|
||||
{
|
||||
$role->update(['name' => $data['name']]);
|
||||
|
||||
$role->syncPermissions($data['permissions'] ?? []);
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\UI\WEB\Controllers;
|
||||
|
||||
use App\Containers\Dashboard\Actions\Roles\CreateRoleAction;
|
||||
use App\Containers\Dashboard\Actions\Roles\DeleteRoleAction;
|
||||
use App\Containers\Dashboard\Actions\Roles\ListRolesAction;
|
||||
use App\Containers\Dashboard\Actions\Roles\UpdateRoleAction;
|
||||
use App\Containers\Dashboard\UI\WEB\Requests\StoreRoleRequest;
|
||||
use App\Containers\Dashboard\UI\WEB\Requests\UpdateRoleRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RolesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListRolesAction $listRolesAction,
|
||||
private readonly CreateRoleAction $createRoleAction,
|
||||
private readonly UpdateRoleAction $updateRoleAction,
|
||||
private readonly DeleteRoleAction $deleteRoleAction,
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
$data = $this->listRolesAction->run();
|
||||
|
||||
return Inertia::render('Dashboard/Roles/Index', $data);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$permissions = Permission::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Dashboard/Roles/Create', [
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreRoleRequest $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->createRoleAction->run($request->validated());
|
||||
|
||||
return redirect()->route('dashboard.roles.index')
|
||||
->with('success', 'Роль успешно создана!');
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('error', 'Ошибка при создании роли: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(Role $role): Response
|
||||
{
|
||||
$role->load('permissions');
|
||||
$permissions = Permission::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Dashboard/Roles/Edit', [
|
||||
'role' => [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'permissions' => $role->permissions->pluck('name')->toArray(),
|
||||
],
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateRoleRequest $request, Role $role): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->updateRoleAction->run($role, $request->validated());
|
||||
|
||||
return redirect()->route('dashboard.roles.index')
|
||||
->with('success', 'Роль успешно обновлена!');
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('error', 'Ошибка при обновлении роли: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Role $role): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->deleteRoleAction->run($role);
|
||||
|
||||
return redirect()->route('dashboard.roles.index')
|
||||
->with('success', 'Роль успешно удалена!');
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->with('error', 'Ошибка при удалении роли: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\UI\WEB\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRoleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => ['string', 'exists:permissions,name'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Название роли обязательно для заполнения',
|
||||
'name.unique' => 'Роль с таким названием уже существует',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Dashboard\UI\WEB\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateRoleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($this->route('role'))],
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => ['string', 'exists:permissions,name'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Название роли обязательно для заполнения',
|
||||
'name.unique' => 'Роль с таким названием уже существует',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class HandleInertiaRequests extends Middleware
|
||||
],
|
||||
'auth' => [
|
||||
'user' => $request->user() ? $request->user()->only('id', 'name', 'email', 'created_at') : null,
|
||||
'permissions' => $request->user()?->getAllPermissions()->pluck('name')->toArray() ?? [],
|
||||
'roles' => $request->user()?->getRoleNames()->toArray() ?? [],
|
||||
],
|
||||
'ziggy' => function() {
|
||||
return array_merge((new Ziggy())->toArray(), [
|
||||
|
||||
@@ -91,5 +91,6 @@ class HttpKernel extends LaravelHttpKernel
|
||||
'limit.post' => LimitPost::class,
|
||||
'form.time.period' => FormTimePeriodMiddleware::class,
|
||||
'vikon.refresh' => \App\Http\Middleware\VikonTokenRefresh::class,
|
||||
'dashboard.permission' => \App\Ship\Middleware\CheckDashboardPermission::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class HandleInertiaRequests extends Middleware
|
||||
],
|
||||
'auth' => [
|
||||
'user' => $request->user() ? $request->user()->only('id', 'name', 'email', 'created_at') : null,
|
||||
'permissions' => $request->user()?->getAllPermissions()->pluck('name')->toArray() ?? [],
|
||||
'roles' => $request->user()?->getRoleNames()->toArray() ?? [],
|
||||
],
|
||||
'ziggy' => fn () => [
|
||||
...(new Ziggy)->toArray(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<div class="space-y-1">
|
||||
<SidebarNavItem
|
||||
v-for="item in menuItems"
|
||||
v-for="item in visibleMenuItems"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:mobile="mobile"
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { menuItems } from './menuConfig';
|
||||
import { quickActions } from './quickActionsConfig';
|
||||
import SidebarNavItem from './SidebarNavItem.vue';
|
||||
@@ -55,15 +56,24 @@ defineProps({
|
||||
|
||||
defineEmits(['child-click']);
|
||||
|
||||
const page = usePage();
|
||||
const userPermissions = computed(() => page.props.auth?.permissions ?? []);
|
||||
|
||||
const visibleMenuItems = computed(() => {
|
||||
return menuItems.filter(item => {
|
||||
if (!item.permission) return true;
|
||||
return userPermissions.value.includes(item.permission);
|
||||
});
|
||||
});
|
||||
|
||||
const iconMap = {
|
||||
cog: DashboardIcon,
|
||||
};
|
||||
|
||||
// Открываем только аккордеон активной страницы
|
||||
const expandedKey = computed(() => {
|
||||
const currentRoute = route().current();
|
||||
|
||||
for (const item of menuItems) {
|
||||
for (const item of visibleMenuItems.value) {
|
||||
if (item.activePrefixes && item.activePrefixes.some(prefix => currentRoute.startsWith(prefix))) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ const iconMap = {
|
||||
'arrow-path': 'arrow-path',
|
||||
'academic-cap': 'academic-cap',
|
||||
'clipboard-document-check': 'clipboard-document-check',
|
||||
'shield-check': 'shield-check',
|
||||
key: 'key',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="plus" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Создание роли</template>
|
||||
<template #header-subtitle>Добавление новой роли в систему</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.roles.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название роли <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Например: editor, viewer, admin"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-danger">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-3">Пермишены</h3>
|
||||
<p class="text-sm text-muted-foreground-1 mb-4">Выберите пермишены для этой роли</p>
|
||||
|
||||
<div v-for="(group, prefix) in groupedPermissions" :key="prefix" class="mb-4">
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2.5 bg-surface/50 border border-layer-line rounded-t-lg cursor-pointer hover:bg-muted-hover transition-colors"
|
||||
@click="toggleGroup(prefix)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isGroupSelected(prefix)"
|
||||
@change.stop="toggleGroup(prefix)"
|
||||
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-foreground">{{ prefixLabels[prefix] || prefix }}</span>
|
||||
<span class="text-xs text-muted-foreground-1">({{ group.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-t-0 border-layer-line rounded-b-lg px-4 py-3 bg-white">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<label
|
||||
v-for="perm in group"
|
||||
:key="perm.name"
|
||||
class="flex items-center gap-2 cursor-pointer hover:bg-muted-hover/50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="perm.name"
|
||||
v-model="form.permissions"
|
||||
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{{ perm.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.permissions" class="mt-1 text-sm text-danger">{{ errors.permissions }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.roles.index')"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ processing ? 'Создание...' : 'Создать роль' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, onMounted } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
const props = defineProps({
|
||||
permissions: { type: Array, required: true },
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
const processing = false;
|
||||
|
||||
const prefixLabels = {
|
||||
view_any: 'Просмотр',
|
||||
create: 'Создание',
|
||||
update: 'Редактирование',
|
||||
delete: 'Удаление',
|
||||
restore: 'Восстановление',
|
||||
force_delete: 'Принудительное удаление',
|
||||
replicate: 'Дублирование',
|
||||
reorder: 'Переупорядочивание',
|
||||
};
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups = {};
|
||||
for (const perm of props.permissions) {
|
||||
const parts = perm.name.split('_');
|
||||
let prefix;
|
||||
if (parts[0] === 'view' && parts[1] === 'any') {
|
||||
prefix = 'view_any';
|
||||
} else if (parts[0] === 'force' && parts[1] === 'delete') {
|
||||
prefix = 'force_delete';
|
||||
} else {
|
||||
prefix = parts[0];
|
||||
}
|
||||
if (!groups[prefix]) groups[prefix] = [];
|
||||
groups[prefix].push(perm);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const isGroupSelected = (prefix) => {
|
||||
const group = groupedPermissions.value[prefix] || [];
|
||||
return group.length > 0 && group.every(p => form.permissions.includes(p.name));
|
||||
};
|
||||
|
||||
const toggleGroup = (prefix) => {
|
||||
const group = groupedPermissions.value[prefix] || [];
|
||||
if (isGroupSelected(prefix)) {
|
||||
const groupNames = group.map(p => p.name);
|
||||
form.permissions = form.permissions.filter(name => !groupNames.includes(name));
|
||||
} else {
|
||||
const groupNames = group.map(p => p.name);
|
||||
form.permissions = [...new Set([...form.permissions, ...groupNames])];
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
router.post(route('dashboard.roles.store'), {
|
||||
name: form.name,
|
||||
permissions: form.permissions,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.title = 'Создание роли — Dashboard';
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Редактирование роли</template>
|
||||
<template #header-subtitle>Изменение параметров роли «{{ role.name }}»</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.roles.index')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
|
||||
>
|
||||
<DashboardIcon name="arrow-left" size="4" />
|
||||
Назад к списку
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название роли <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Например: editor, viewer, admin"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-danger">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-3">Пермишены</h3>
|
||||
<p class="text-sm text-muted-foreground-1 mb-4">Выберите пермишены для этой роли</p>
|
||||
|
||||
<div v-for="(group, prefix) in groupedPermissions" :key="prefix" class="mb-4">
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2.5 bg-surface/50 border border-layer-line rounded-t-lg cursor-pointer hover:bg-muted-hover transition-colors"
|
||||
@click="toggleGroup(prefix)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isGroupSelected(prefix)"
|
||||
@change.stop="toggleGroup(prefix)"
|
||||
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-foreground">{{ prefixLabels[prefix] || prefix }}</span>
|
||||
<span class="text-xs text-muted-foreground-1">({{ group.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-t-0 border-layer-line rounded-b-lg px-4 py-3 bg-white">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<label
|
||||
v-for="perm in group"
|
||||
:key="perm.name"
|
||||
class="flex items-center gap-2 cursor-pointer hover:bg-muted-hover/50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="perm.name"
|
||||
v-model="form.permissions"
|
||||
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{{ perm.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.permissions" class="mt-1 text-sm text-danger">{{ errors.permissions }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
|
||||
<a
|
||||
:href="route('dashboard.roles.index')"
|
||||
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, onMounted } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
|
||||
const props = defineProps({
|
||||
role: { type: Object, required: true },
|
||||
permissions: { type: Array, required: true },
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: props.role.name,
|
||||
permissions: [...props.role.permissions],
|
||||
});
|
||||
|
||||
const processing = false;
|
||||
|
||||
const prefixLabels = {
|
||||
view_any: 'Просмотр',
|
||||
create: 'Создание',
|
||||
update: 'Редактирование',
|
||||
delete: 'Удаление',
|
||||
restore: 'Восстановление',
|
||||
force_delete: 'Принудительное удаление',
|
||||
replicate: 'Дублирование',
|
||||
reorder: 'Переупорядочивание',
|
||||
};
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups = {};
|
||||
for (const perm of props.permissions) {
|
||||
const parts = perm.name.split('_');
|
||||
let prefix;
|
||||
if (parts[0] === 'view' && parts[1] === 'any') {
|
||||
prefix = 'view_any';
|
||||
} else if (parts[0] === 'force' && parts[1] === 'delete') {
|
||||
prefix = 'force_delete';
|
||||
} else {
|
||||
prefix = parts[0];
|
||||
}
|
||||
if (!groups[prefix]) groups[prefix] = [];
|
||||
groups[prefix].push(perm);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const isGroupSelected = (prefix) => {
|
||||
const group = groupedPermissions.value[prefix] || [];
|
||||
return group.length > 0 && group.every(p => form.permissions.includes(p.name));
|
||||
};
|
||||
|
||||
const toggleGroup = (prefix) => {
|
||||
const group = groupedPermissions.value[prefix] || [];
|
||||
if (isGroupSelected(prefix)) {
|
||||
const groupNames = group.map(p => p.name);
|
||||
form.permissions = form.permissions.filter(name => !groupNames.includes(name));
|
||||
} else {
|
||||
const groupNames = group.map(p => p.name);
|
||||
form.permissions = [...new Set([...form.permissions, ...groupNames])];
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
router.put(route('dashboard.roles.update', props.role.id), {
|
||||
name: form.name,
|
||||
permissions: form.permissions,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.title = 'Редактирование роли — Dashboard';
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<template #header-icon>
|
||||
<DashboardIcon name="shield-check" size="5" class="text-primary" />
|
||||
</template>
|
||||
<template #header-title>Роли</template>
|
||||
<template #header-subtitle>Управление ролями и пермишенами</template>
|
||||
<template #header-actions>
|
||||
<a
|
||||
:href="route('dashboard.roles.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<DashboardIcon name="plus" size="4" />
|
||||
Создать роль
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template #breadcrumbs>
|
||||
<Breadcrumbs :crumbs="[{ label: 'Роли', href: route('dashboard.roles.index') }]" />
|
||||
</template>
|
||||
|
||||
<FlashMessages />
|
||||
|
||||
<!-- Roles Table -->
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-foreground">
|
||||
Всего: <span class="font-medium">{{ roles.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-line-2">
|
||||
<thead class="bg-surface/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Пермишены
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line-2">
|
||||
<tr
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-if="role.permissions && role.permissions.length > 0"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary"
|
||||
>
|
||||
{{ role.permissions.length }} пермишенов
|
||||
</span>
|
||||
<span v-else class="text-xs text-muted-foreground-1">Нет пермишенов</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
:href="route('dashboard.roles.edit', role.id)"
|
||||
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
|
||||
title="Редактировать"
|
||||
>
|
||||
<DashboardIcon name="pencil-square" size="4" />
|
||||
</a>
|
||||
<button
|
||||
@click.prevent="CONFIRM_AND_DELETE(role, 'dashboard.roles.destroy', {
|
||||
message: 'Удалить роль «' + role.name + '»?'
|
||||
})"
|
||||
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmptyState
|
||||
v-if="roles.length === 0"
|
||||
:columns="3"
|
||||
title="Роли не найдены"
|
||||
description="Создайте первую роль для управления доступом"
|
||||
:action-url="route('dashboard.roles.create')"
|
||||
action-text="Создать роль"
|
||||
icon-path="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import DashboardLayout from '../Components/DashboardLayout.vue';
|
||||
import DashboardIcon from '../Components/DashboardIcon.vue';
|
||||
import Breadcrumbs from '../Components/shared/Breadcrumbs.vue';
|
||||
import FlashMessages from '../Components/shared/FlashMessages.vue';
|
||||
import EmptyState from '../Components/shared/EmptyState.vue';
|
||||
|
||||
defineProps({
|
||||
roles: { type: Array, required: true },
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.title = 'Роли — Dashboard';
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user