feat: implement role management system with CRUD interface and permission redefinitions

This commit is contained in:
Yoga Pangestu 2026-06-14 02:45:31 +07:00
parent cffea55157
commit 691916e6de
15 changed files with 969 additions and 116 deletions

View File

@ -103,6 +103,11 @@ enum Permission: string
case ACTIVITY_LOGS_VIEW = 'activity-logs.view';
case ROLES_VIEW = 'roles.view';
case ROLES_CREATE = 'roles.create';
case ROLES_UPDATE = 'roles.update';
case ROLES_DELETE = 'roles.delete';
public function label(): string
{
return match ($this) {
@ -200,6 +205,11 @@ public function label(): string
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Lihat Log Aktivitas',
self::ROLES_VIEW => 'Lihat Role',
self::ROLES_CREATE => 'Tambah Role',
self::ROLES_UPDATE => 'Ubah Role',
self::ROLES_DELETE => 'Hapus Role',
};
}
@ -241,6 +251,8 @@ public function group(): string
self::PAYROLL_CLOSE => 'Gaji',
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
self::ROLES_DELETE => 'Role & Permission',
};
}

View File

@ -36,113 +36,84 @@ public function permissions(): array
{
return match ($this) {
self::DEVELOPER, self::OWNER => Permission::cases(),
self::DIREKTUR => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::EMPLOYEES_CREATE,
Permission::EMPLOYEES_UPDATE,
Permission::EMPLOYEES_DELETE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::ATTENDANCES_MANAGE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::PURCHASES_VIEW,
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CUTTINGS_VIEW,
Permission::CUTTINGS_CREATE,
Permission::CUTTINGS_UPDATE,
Permission::CUTTINGS_DELETE,
Permission::CUTTINGS_COMPLETE,
Permission::CUTTINGS_VERIFY,
Permission::CUTTINGS_REJECT,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
Permission::EXPENSES_VIEW,
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
Permission::PAYROLL_PAY,
Permission::PAYROLL_ADJUST,
Permission::PAYROLL_CLOSE,
Permission::ACTIVITY_LOGS_VIEW,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::EMPLOYEES_CREATE,
Permission::EMPLOYEES_UPDATE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::ATTENDANCES_MANAGE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
@ -150,84 +121,128 @@ public function permissions(): array
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CUTTINGS_VIEW,
Permission::CUTTINGS_VERIFY,
Permission::CUTTINGS_REJECT,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
Permission::EXPENSES_VIEW,
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
Permission::PAYROLL_PAY,
Permission::PAYROLL_ADJUST,
Permission::PAYROLL_CLOSE,
Permission::SETTINGS_VIEW,
Permission::SETTINGS_UPDATE,
Permission::ACTIVITY_LOGS_VIEW,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::PURCHASES_VIEW,
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
Permission::CUTTINGS_VIEW,
Permission::CUTTINGS_CREATE,
Permission::CUTTINGS_UPDATE,
Permission::CUTTINGS_DELETE,
Permission::CUTTINGS_COMPLETE,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CUTTINGS_VIEW,
Permission::CUTTINGS_VERIFY,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
Permission::EXPENSES_VIEW,
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
Permission::PAYROLL_PAY,
Permission::PAYROLL_ADJUST,
Permission::PAYROLL_CLOSE,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
@ -235,6 +250,7 @@ public function permissions(): array
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,
],

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Enums\Permission as PermissionEnum;
use App\Enums\Role as EnumsRole;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\System\RoleRequest;
use App\Services\System\RoleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
use FlashesEntityMessage;
use ParsesDataTableQuery;
public function __construct(
private readonly RoleService $roleService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/system/roles/Index', [
'roles' => $this->roleService->paginateForIndex($tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function create(): Response
{
$permissions = collect(PermissionEnum::cases())
->map(fn (PermissionEnum $permission) => [
'value' => $permission->value,
'label' => $permission->label(),
'group' => $permission->group(),
])
->values()
->all();
return Inertia::render('admin/system/roles/Create', [
'permissions' => $permissions,
]);
}
public function store(RoleRequest $request): RedirectResponse
{
$this->roleService->create($request->validated());
$this->flashCreated('Role');
return redirect()->route('admin.system.roles.index');
}
public function edit(Role $role): Response
{
$role->load('permissions');
$permissions = collect(PermissionEnum::cases())
->map(fn (PermissionEnum $permission) => [
'value' => $permission->value,
'label' => $permission->label(),
'group' => $permission->group(),
])
->values()
->all();
// Check if role is developer or owner to protect it
$isProtected = in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true);
return Inertia::render('admin/system/roles/Edit', [
'role' => [
'id' => $role->id,
'name' => $role->name,
'permission_names' => $role->permissions->pluck('name')->all(),
],
'permissions' => $permissions,
'isProtected' => $isProtected,
]);
}
public function update(RoleRequest $request, Role $role): RedirectResponse
{
$this->roleService->update($role, $request->validated());
$this->flashUpdated('Role');
return redirect()->route('admin.system.roles.index');
}
public function destroy(Role $role): RedirectResponse
{
try {
$this->roleService->delete($role);
$this->flashDeleted('Role');
} catch (\InvalidArgumentException $e) {
$this->flashError($e->getMessage());
}
return redirect()->route('admin.system.roles.index');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Admin\System;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class RoleRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::ROLES_CREATE
: Permission::ROLES_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
protected function prepareForValidation(): void
{
if ($this->has('name')) {
$this->merge([
'name' => Str::slug($this->input('name')),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$roleId = $this->route('role')?->id;
return [
'name' => [
'required',
'string',
'max:50',
'regex:/^[a-z0-9\-]+$/',
Rule::unique('roles', 'name')->ignore($roleId),
],
'permissions' => ['nullable', 'array'],
'permissions.*' => ['string', Rule::exists('permissions', 'name')],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => 'nama',
'permissions' => 'hak akses',
];
}
}

View File

@ -93,7 +93,13 @@ public function roleLabel(): Attribute
get: function () {
$role = $this->roles->first();
return $role ? Role::from($role->name)->label() : null;
if (! $role) {
return null;
}
$enum = Role::tryFrom($role->name);
return $enum ? $enum->label() : ucwords(str_replace('-', ' ', $role->name));
},
);
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services\System;
use App\Enums\Role as EnumsRole;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
class RoleService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Role::query()
->withCount('permissions')
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%");
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
DB::transaction(function () use ($validated): void {
$role = Role::create([
'name' => Str::slug($validated['name']),
'guard_name' => 'web',
]);
if (! empty($validated['permissions'])) {
$role->syncPermissions($validated['permissions']);
}
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Role $role, array $validated): void
{
DB::transaction(function () use ($role, $validated): void {
// Protect developer and owner names from changing
if (! in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true)) {
$role->name = Str::slug($validated['name']);
}
$role->save();
$role->syncPermissions($validated['permissions'] ?? []);
});
}
public function delete(Role $role): void
{
if (in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true)) {
throw new \InvalidArgumentException('Role developer atau owner tidak dapat dihapus.');
}
$role->delete();
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -33,30 +33,6 @@ public function run(): void
);
}
$legacyRoleMap = [
'super-admin' => RoleEnum::DEVELOPER->value,
'admin' => RoleEnum::DIREKTUR->value,
'hr' => RoleEnum::ADMIN_TOKO->value,
'user' => RoleEnum::NON_OPERATOR->value,
];
foreach ($legacyRoleMap as $oldRole => $newRole) {
$oldRoleModel = Role::query()->where('name', $oldRole)->where('guard_name', 'web')->first();
if (! $oldRoleModel) {
continue;
}
$newRoleModel = Role::findByName($newRole, 'web');
foreach ($oldRoleModel->users as $user) {
$user->removeRole($oldRole);
$user->assignRole($newRoleModel);
}
$oldRoleModel->delete();
}
Role::query()
->where('guard_name', 'web')
->whereNotIn('name', RoleEnum::values())

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, Shield, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import {
Sidebar,
SidebarContent,
@ -75,6 +75,7 @@ const menuGroups: MenuGroup[] = [
{
label: 'Sistem',
items: [
{ title: 'Role & Permission', href: '/admin/system/roles', icon: Shield, permission: 'roles.view' },
{ title: 'Pengaturan', href: '/admin/system/setting', icon: Settings2, permission: 'settings.view' },
{ title: 'Log Aktivitas', href: '/admin/system/activity-logs', icon: History, permission: 'activity-logs.view' },
],

View File

@ -0,0 +1,227 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form';
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
interface PermissionOption {
value: string;
label: string;
group: string;
}
const props = withDefaults(
defineProps<{
permissions: PermissionOption[];
initialData?: {
name?: string;
permission_names?: string[];
};
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
isProtected?: boolean;
}>(),
{
method: 'post',
submitLabel: 'Simpan',
isProtected: false,
},
);
const form = useForm({
name: props.initialData?.name ?? '',
permissions: props.initialData?.permission_names ?? [],
});
// Group permissions by group/module
const groupedPermissions = computed(() => {
const groups: Record<string, PermissionOption[]> = {};
props.permissions.forEach((perm) => {
if (!groups[perm.group]) {
groups[perm.group] = [];
}
groups[perm.group].push(perm);
});
return groups;
});
function togglePermission(permValue: string, checked: boolean) {
if (checked) {
if (!form.permissions.includes(permValue)) {
form.permissions = [...form.permissions, permValue];
}
} else {
form.permissions = form.permissions.filter((p) => p !== permValue);
}
}
function isPermissionChecked(permValue: string): boolean {
return form.permissions.includes(permValue);
}
// Check/Uncheck all for a specific module group
function isGroupAllChecked(groupName: string): boolean {
const groupPerms = groupedPermissions.value[groupName] || [];
if (groupPerms.length === 0) return false;
return groupPerms.every((perm) => form.permissions.includes(perm.value));
}
function toggleGroupAll(groupName: string, checked: boolean) {
const groupPerms = groupedPermissions.value[groupName] || [];
const groupValues = groupPerms.map((p) => p.value);
if (checked) {
// Add all group permissions that are not already in form.permissions
const toAdd = groupValues.filter((v) => !form.permissions.includes(v));
form.permissions = [...form.permissions, ...toAdd];
} else {
// Remove all group permissions
form.permissions = form.permissions.filter(
(p) => !groupValues.includes(p),
);
}
}
function submit() {
form[props.method](props.submitUrl, {
onSuccess: () => {
toast.success(
`Role berhasil ${props.method === 'post' ? 'dibuat' : 'diperbarui'}.`,
);
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
}
</script>
<template>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Role</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="name" required
>Nama</FieldLabel
>
<Input
id="name"
v-model="form.name"
type="text"
placeholder="Contoh: admin-gudang"
:disabled="isProtected"
/>
<span
v-if="isProtected"
class="mt-1 text-xs text-muted-foreground"
>
Nama bawaan sistem (developer/owner) tidak
dapat diubah.
</span>
<FieldError
:errors="formErrors(form, 'name')"
/>
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Hak Akses (Permissions)</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-6">
<div
v-for="(perms, groupName) in groupedPermissions"
:key="groupName"
class="border-b pb-6 last:border-b-0 last:pb-0"
>
<div class="mb-3 flex items-center justify-between">
<h3
class="text-base font-semibold text-foreground"
>
{{ groupName }}
</h3>
<label
class="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<input
type="checkbox"
class="size-3.5 cursor-pointer rounded border-input"
:checked="isGroupAllChecked(groupName)"
@change="
toggleGroupAll(
groupName,
(
$event.target as HTMLInputElement
).checked,
)
"
/>
<span>Pilih Semua</span>
</label>
</div>
<div
class="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<label
v-for="perm in perms"
:key="perm.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 transition-colors hover:bg-muted/50"
>
<input
type="checkbox"
class="size-4 cursor-pointer rounded border-input"
:checked="
isPermissionChecked(perm.value)
"
@change="
togglePermission(
perm.value,
(
$event.target as HTMLInputElement
).checked,
)
"
/>
<span class="text-sm">{{
perm.label
}}</span>
</label>
</div>
</div>
<FieldError :errors="formErrors(form, 'permissions')" />
</div>
</CardContent>
</Card>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</div>
</div>
</form>
</template>

View File

@ -0,0 +1,62 @@
import DataTableActions from '@/components/admin/system/roles/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
export interface RoleListItem {
id: number;
name: string;
permissions_count: number;
}
export function createColumns(): ColumnDef<RoleListItem>[] {
return [
{
accessorKey: 'name',
enableSorting: true,
header: () =>
h(DataTableColumnHeader, { title: 'Nama', column: 'name' }),
cell: ({ row }) => {
const name = row.original.name;
const formattedName = name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return h('div', { class: 'flex flex-col' }, [
h(
'span',
{ class: 'font-medium text-foreground' },
formattedName,
),
h(
'span',
{ class: 'text-xs text-muted-foreground font-mono' },
name,
),
]);
},
},
{
accessorKey: 'permissions_count',
enableSorting: false,
header: 'Jumlah Hak Akses',
cell: ({ row }) => {
return h(
'span',
{ class: 'text-sm' },
`${row.original.permissions_count} hak akses`,
);
},
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) =>
h(DataTableActions, {
role: row.original,
}),
},
];
}

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { RoleListItem } from './columns';
const props = defineProps<{
role: RoleListItem;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const isSystemRole = computed(() => {
return ['developer', 'owner'].includes(props.role.name);
});
function destroyRole() {
if (isSystemRole.value) {
toast.error('Role sistem tidak dapat dihapus.');
return;
}
deleteProcessing.value = true;
router.delete(`/admin/system/roles/${props.role.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
toast.success('Role berhasil dihapus.');
},
onError: () => {
toast.error('Gagal menghapus role.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('roles.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/system/roles/${role.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('roles.delete')">
<TooltipTrigger as-child>
<span>
<Button variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-8"
:disabled="isSystemRole"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{{ isSystemRole ? 'Role sistem tidak dapat dihapus' : 'Hapus' }}
</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog v-if="can('roles.delete') && !isSystemRole" v-model:open="deleteConfirmOpen" title="Hapus role?"
:description="`Role ${role.name} akan dihapus secara permanen. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyRole" />
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RoleForm from '@/components/admin/system/roles/RoleForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
interface PermissionOption {
value: string;
label: string;
group: string;
}
defineProps<{
permissions: PermissionOption[];
}>();
</script>
<template>
<Head title="Tambah Role" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Tambah Role
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/system/roles">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<RoleForm submit-url="/admin/system/roles" method="post" submit-label="Simpan" :permissions="permissions" />
</AdminLayout>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RoleForm from '@/components/admin/system/roles/RoleForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
interface PermissionOption {
value: string;
label: string;
group: string;
}
interface RoleData {
id: number;
name: string;
permission_names: string[];
}
const props = defineProps<{
role: RoleData;
permissions: PermissionOption[];
isProtected: boolean;
}>();
</script>
<template>
<Head title="Ubah Role" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Ubah Role
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/system/roles">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<RoleForm :submit-url="`/admin/system/roles/${role.id}`" method="put" submit-label="Perbarui"
:initial-data="role" :permissions="permissions" :is-protected="isProtected" />
</AdminLayout>
</template>

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { createColumns } from '@/components/admin/system/roles/columns';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import type { RoleListItem } from '@/components/admin/system/roles/columns';
interface PaginatedRoles {
current_page: number;
per_page: number;
last_page: number;
total: number;
data: RoleListItem[];
links: any[];
}
const props = defineProps<{
roles: PaginatedRoles;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/system/roles',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns());
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.roles.current_page,
perPage: props.roles.per_page,
lastPage: props.roles.last_page,
total: props.roles.total,
}));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Role & Permission" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Role & Permission
</h2>
</div>
<Button v-if="can('roles.create')" class="shrink-0 self-start sm:self-center" as-child>
<Link href="/admin/system/roles/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="roles.data" :pagination="pagination"
:pagination-links="roles.links" :sort="currentSort" @sort-change="setSort"
@filters-reset="resetFilters" />
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -24,6 +24,7 @@
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\System\ActivityLogController;
use App\Http\Controllers\Admin\System\RoleController;
use App\Http\Controllers\Admin\System\SettingController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
@ -273,6 +274,27 @@
});
Route::prefix('system')->name('system.')->group(function () {
Route::prefix('roles')->name('roles.')
->middleware('permission:'.Permission::ROLES_VIEW->value)
->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('index');
Route::get('create', [RoleController::class, 'create'])
->middleware('permission:'.Permission::ROLES_CREATE->value)
->name('create');
Route::post('/', [RoleController::class, 'store'])
->middleware('permission:'.Permission::ROLES_CREATE->value)
->name('store');
Route::get('{role}/edit', [RoleController::class, 'edit'])
->middleware('permission:'.Permission::ROLES_UPDATE->value)
->name('edit');
Route::put('{role}', [RoleController::class, 'update'])
->middleware('permission:'.Permission::ROLES_UPDATE->value)
->name('update');
Route::delete('{role}', [RoleController::class, 'destroy'])
->middleware('permission:'.Permission::ROLES_DELETE->value)
->name('destroy');
});
Route::prefix('activity-logs')->name('activity-logs.')
->middleware('permission:'.Permission::ACTIVITY_LOGS_VIEW->value)
->group(function () {