feat: implement Role management system with CRUD operations, including role creation, editing, and deletion, along with permission assignment
This commit is contained in:
parent
0fdd02e9e4
commit
96ad0de44c
112
app/Http/Controllers/Admin/System/RoleController.php
Normal file
112
app/Http/Controllers/Admin/System/RoleController.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\System\RoleRequest;
|
||||
use App\Support\LogHelper;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/system/role/index', [
|
||||
'roles' => Role::with('permissions')->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/system/role/create', [
|
||||
'permissions' => Permission::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(RoleRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated) {
|
||||
$role = Role::create([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => $validated['guard_name'],
|
||||
]);
|
||||
|
||||
$role->syncPermissions($validated['permissions'] ?? []);
|
||||
});
|
||||
|
||||
return redirect()->route('system.role.index')->with('success', 'Data berhasil disimpan');
|
||||
} catch (\Throwable $e) {
|
||||
LogHelper::logException($e, 'Failed to store role', [
|
||||
'name' => $validated['name'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('error', 'Terjadi kesalahan, silakan hubungi pengembang');
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(Role $role): Response
|
||||
{
|
||||
$role->load('permissions');
|
||||
|
||||
return Inertia::render('admin/system/role/edit', [
|
||||
'role' => $role,
|
||||
'permissions' => Permission::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(RoleRequest $request, Role $role): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($role, $validated) {
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => $validated['guard_name'],
|
||||
]);
|
||||
|
||||
$role->syncPermissions($validated['permissions'] ?? []);
|
||||
});
|
||||
|
||||
return redirect()->route('system.role.index')->with('success', 'Data berhasil diperbarui');
|
||||
} catch (\Throwable $e) {
|
||||
LogHelper::logException($e, 'Failed to update role', [
|
||||
'role_id' => $role->id,
|
||||
'name' => $validated['name'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('error', 'Terjadi kesalahan, silakan hubungi pengembang');
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Role $role): RedirectResponse
|
||||
{
|
||||
$role->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil dihapus');
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request): RedirectResponse
|
||||
{
|
||||
$ids = $request->input('ids');
|
||||
|
||||
Role::whereIn('id', $ids)->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Admin/System/RoleRequest.php
Normal file
31
app/Http/Requests/Admin/System/RoleRequest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\System;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RoleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$roleId = $this->route('role')?->id;
|
||||
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('roles', 'name')->ignore($roleId),
|
||||
],
|
||||
'guard_name' => ['required', 'string'],
|
||||
'permissions' => ['nullable', 'array'],
|
||||
'permissions.*' => ['nullable', Rule::exists('permissions', 'id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,12 @@ public function run(): void
|
||||
Permission::firstOrCreate(['name' => 'View:Setting', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'Edit:Setting', 'guard_name' => 'web']);
|
||||
|
||||
// Permissions Role
|
||||
Permission::firstOrCreate(['name' => 'View:Role', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'Create:Role', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'Edit:Role', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'Delete:Role', 'guard_name' => 'web']);
|
||||
|
||||
// Permissions Log
|
||||
Permission::firstOrCreate(['name' => 'View:Log', 'guard_name' => 'web']);
|
||||
|
||||
@ -98,7 +104,14 @@ public function run(): void
|
||||
$developer->syncPermissions($allPermissions);
|
||||
|
||||
// Assign permissions to Owner (All except View:Log)
|
||||
$ownerPermissions = Permission::whereNotIn('name', ['View:Log'])->get();
|
||||
$ownerPermissions = Permission::whereNotIn('name', [
|
||||
'View:Log',
|
||||
|
||||
'View:Role',
|
||||
'Create:Role',
|
||||
'Edit:Role',
|
||||
'Delete:Role',
|
||||
])->get();
|
||||
$owner->syncPermissions($ownerPermissions);
|
||||
|
||||
// Assign permissions to Admin
|
||||
@ -138,6 +151,11 @@ public function run(): void
|
||||
'View:Setting',
|
||||
'Edit:Setting',
|
||||
|
||||
'View:Role',
|
||||
'Create:Role',
|
||||
'Edit:Role',
|
||||
'Delete:Role',
|
||||
|
||||
'View:Log',
|
||||
|
||||
'View:Activity',
|
||||
|
||||
@ -11,7 +11,21 @@ import {
|
||||
import { analysis, dashboard } from '@/routes';
|
||||
import category from '@/routes/category';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BarChart3, Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingBag, ShoppingCart, User, Wallet } from 'lucide-react';
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
DollarSign,
|
||||
History,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
User,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
|
||||
import expense from '@/routes/expense';
|
||||
import order from '@/routes/order';
|
||||
@ -21,8 +35,8 @@ import purchase from '@/routes/purchase';
|
||||
import system from '@/routes/system';
|
||||
import user from '@/routes/user';
|
||||
import type { NavItem } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import type { Auth } from '@/types/auth';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
@ -97,6 +111,12 @@ const systemNavItems: NavItem[] = [
|
||||
icon: Settings,
|
||||
permission: 'View:Setting',
|
||||
},
|
||||
{
|
||||
title: 'Role & Izin',
|
||||
href: system.role.index().url,
|
||||
icon: ShieldCheck,
|
||||
permission: 'View:Role',
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
href: system.logs.index().url,
|
||||
@ -112,12 +132,12 @@ const systemNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth } = usePage<{auth: Auth}>().props;
|
||||
const { auth } = usePage<{ auth: Auth }>().props;
|
||||
|
||||
const filterNavItems = (items: NavItem[]) => {
|
||||
return items.filter(item => {
|
||||
return items.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
return auth.permissions.includes(item.permission) || auth.roles.includes('Developer');
|
||||
return auth.permissions.includes(item.permission);
|
||||
});
|
||||
};
|
||||
|
||||
@ -136,11 +156,33 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{filterNavItems(mainNavItems).length > 0 && <NavMain items={filterNavItems(mainNavItems)} />}
|
||||
{filterNavItems(masterNavItems).length > 0 && <NavMain items={filterNavItems(masterNavItems)} label='Master' />}
|
||||
{filterNavItems(manageNavItems).length > 0 && <NavMain items={filterNavItems(manageNavItems)} label='Kelola' />}
|
||||
{filterNavItems(financeNavItems).length > 0 && <NavMain items={filterNavItems(financeNavItems)} label='Keuangan' />}
|
||||
{filterNavItems(systemNavItems).length > 0 && <NavMain items={filterNavItems(systemNavItems)} label='Sistem' />}
|
||||
{filterNavItems(mainNavItems).length > 0 && (
|
||||
<NavMain items={filterNavItems(mainNavItems)} />
|
||||
)}
|
||||
{filterNavItems(masterNavItems).length > 0 && (
|
||||
<NavMain
|
||||
items={filterNavItems(masterNavItems)}
|
||||
label="Master"
|
||||
/>
|
||||
)}
|
||||
{filterNavItems(manageNavItems).length > 0 && (
|
||||
<NavMain
|
||||
items={filterNavItems(manageNavItems)}
|
||||
label="Kelola"
|
||||
/>
|
||||
)}
|
||||
{filterNavItems(financeNavItems).length > 0 && (
|
||||
<NavMain
|
||||
items={filterNavItems(financeNavItems)}
|
||||
label="Keuangan"
|
||||
/>
|
||||
)}
|
||||
{filterNavItems(systemNavItems).length > 0 && (
|
||||
<NavMain
|
||||
items={filterNavItems(systemNavItems)}
|
||||
label="Sistem"
|
||||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
VisibilityState} from "@tanstack/react-table";
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import { Settings2, ChevronDown, ListFilter, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@ -26,21 +25,32 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ListFilter,
|
||||
Settings2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -48,10 +58,10 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
interface DataTableFilterOption {
|
||||
columnId: string;
|
||||
@ -63,7 +73,7 @@ export interface DataTableBulkAction<TData> {
|
||||
label: string;
|
||||
onClick: (rows: TData[]) => void;
|
||||
icon?: React.ElementType;
|
||||
variant?: "default" | "destructive";
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@ -89,17 +99,19 @@ export function DataTable<TData, TValue>({
|
||||
onRowSelectionChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
const [columnFilters, setColumnFilters] =
|
||||
React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [globalFilter, setGlobalFilter] = React.useState('');
|
||||
|
||||
const finalColumns = React.useMemo(() => {
|
||||
const computedColumns = [...columns];
|
||||
|
||||
if (showNumbering) {
|
||||
const numberingColumn: ColumnDef<TData, TValue> = {
|
||||
id: "numbering",
|
||||
header: "No.",
|
||||
id: 'numbering',
|
||||
header: 'No.',
|
||||
cell: ({ row }) => row.index + 1,
|
||||
enableHiding: false,
|
||||
};
|
||||
@ -108,14 +120,17 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
if (showSelection) {
|
||||
const selectColumn: ColumnDef<TData, TValue> = {
|
||||
id: "select",
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
(table.getIsSomePageRowsSelected() &&
|
||||
'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
@ -160,31 +175,51 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-1 flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-1 flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
placeholder="Cari..."
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
value={globalFilter ?? ''}
|
||||
onChange={(event) =>
|
||||
setGlobalFilter(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
{columnFilters.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{columnFilters.map((filter) => {
|
||||
const filterDef = filters?.find((f) => f.columnId === filter.id);
|
||||
const option = filterDef?.options.find((o) => String(o.value) === String(filter.value));
|
||||
const label = option ? option.label : String(filter.value);
|
||||
const filterDef = filters?.find(
|
||||
(f) => f.columnId === filter.id,
|
||||
);
|
||||
const option = filterDef?.options.find(
|
||||
(o) =>
|
||||
String(o.value) ===
|
||||
String(filter.value),
|
||||
);
|
||||
const label = option
|
||||
? option.label
|
||||
: String(filter.value);
|
||||
|
||||
return (
|
||||
<Badge key={filter.id} variant="secondary" className="h-8 gap-1 pr-1 font-normal text-nowrap">
|
||||
<span className="text-muted-foreground mr-1">{filterDef?.title || filter.id}:</span>
|
||||
<Badge
|
||||
key={filter.id}
|
||||
variant="secondary"
|
||||
className="h-8 gap-1 pr-1 font-normal text-nowrap"
|
||||
>
|
||||
<span className="mr-1 text-muted-foreground">
|
||||
{filterDef?.title || filter.id}:
|
||||
</span>
|
||||
{label}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 hover:bg-transparent"
|
||||
onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
|
||||
onClick={() =>
|
||||
table
|
||||
.getColumn(filter.id)
|
||||
?.setFilterValue(undefined)
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
@ -196,7 +231,7 @@ export function DataTable<TData, TValue>({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setColumnFilters([]);
|
||||
setGlobalFilter("");
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
@ -207,11 +242,14 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selectedRows.length > 0 && bulkActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" className="gap-2 animate-in fade-in slide-in-from-left-2 transition-all">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="animate-in gap-2 transition-all fade-in slide-in-from-left-2"
|
||||
>
|
||||
Aksi Massal ({selectedRows.length})
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -220,10 +258,22 @@ export function DataTable<TData, TValue>({
|
||||
{bulkActions.map((action, i) => (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onClick={() => action.onClick(selectedRows.map((r) => r.original))}
|
||||
className={action.variant === "destructive" ? "text-red-600 focus:text-red-600" : ""}
|
||||
onClick={() =>
|
||||
action.onClick(
|
||||
selectedRows.map(
|
||||
(r) => r.original,
|
||||
),
|
||||
)
|
||||
}
|
||||
className={
|
||||
action.variant === 'destructive'
|
||||
? 'text-red-600 focus:text-red-600'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{action.icon && <action.icon className="mr-2 h-4 w-4" />}
|
||||
{action.icon && (
|
||||
<action.icon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@ -240,14 +290,18 @@ export function DataTable<TData, TValue>({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Filter Berdasarkan</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
Filter Berdasarkan
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{filters.map((filter) => {
|
||||
const column = table.getColumn(filter.columnId);
|
||||
const column = table.getColumn(
|
||||
filter.columnId,
|
||||
);
|
||||
|
||||
if (!column) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={filter.columnId}>
|
||||
@ -256,29 +310,51 @@ return null;
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onClick={() =>
|
||||
column.setFilterValue(
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
Semua
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{filter.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
if (option.value === "true") {
|
||||
column.setFilterValue(true);
|
||||
} else if (option.value === "false") {
|
||||
column.setFilterValue(false);
|
||||
} else {
|
||||
column.setFilterValue(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{filter.options.map(
|
||||
(option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
option.value ===
|
||||
'true'
|
||||
) {
|
||||
column.setFilterValue(
|
||||
true,
|
||||
);
|
||||
} else if (
|
||||
option.value ===
|
||||
'false'
|
||||
) {
|
||||
column.setFilterValue(
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
column.setFilterValue(
|
||||
option.value,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
@ -295,7 +371,9 @@ column.setFilterValue(option.value);
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Tampilan Kolom</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
Tampilan Kolom
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
@ -306,13 +384,20 @@ column.setFilterValue(option.value);
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{(column.columnDef.meta as any)?.title ||
|
||||
(typeof column.columnDef.header === "string"
|
||||
{(column.columnDef.meta as any)
|
||||
?.title ||
|
||||
(typeof column.columnDef
|
||||
.header === 'string'
|
||||
? column.columnDef.header
|
||||
: String(column.id).replace(/_/g, " "))}
|
||||
: String(column.id).replace(
|
||||
/_/g,
|
||||
' ',
|
||||
))}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
@ -331,14 +416,19 @@ column.setFilterValue(option.value);
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
header.column.id === "select" || header.column.id === "numbering"
|
||||
? "w-10 text-center"
|
||||
: ""
|
||||
header.column.id === 'select' ||
|
||||
header.column.id === 'numbering'
|
||||
? 'w-10 text-center'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
@ -348,24 +438,36 @@ column.setFilterValue(option.value);
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && 'selected'
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
cell.column.id === "select" || cell.column.id === "numbering"
|
||||
? "w-10 text-center"
|
||||
: ""
|
||||
cell.column.id === 'select' ||
|
||||
cell.column.id === 'numbering'
|
||||
? 'w-10 text-center'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={finalColumns.length} className="h-24 text-center">
|
||||
<TableCell
|
||||
colSpan={finalColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Ooops...</EmptyTitle>
|
||||
@ -381,26 +483,33 @@ column.setFilterValue(option.value);
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-4">
|
||||
<div className="flex flex-col items-center justify-between gap-4 py-4 sm:flex-row">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} dari{" "}
|
||||
{table.getFilteredSelectedRowModel().rows.length} dari{' '}
|
||||
{table.getFilteredRowModel().rows.length} baris dipilih.
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">Baris per halaman</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
table.getState().pagination.pageSize
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
<SelectItem
|
||||
key={pageSize}
|
||||
value={`${pageSize}`}
|
||||
>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
@ -410,12 +519,22 @@ column.setFilterValue(option.value);
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{table.getFilteredRowModel().rows.length > 0 ? (
|
||||
<>
|
||||
Menampilkan {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} sampai{" "}
|
||||
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, table.getFilteredRowModel().rows.length)}{" "}
|
||||
dari {table.getFilteredRowModel().rows.length} data
|
||||
Menampilkan{' '}
|
||||
{table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1}{' '}
|
||||
sampai{' '}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex +
|
||||
1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)}{' '}
|
||||
dari {table.getFilteredRowModel().rows.length}{' '}
|
||||
data
|
||||
</>
|
||||
) : (
|
||||
"Tidak ada data"
|
||||
'Tidak ada data'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -449,7 +568,9 @@ column.setFilterValue(option.value);
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
onClick={() =>
|
||||
table.setPageIndex(table.getPageCount() - 1)
|
||||
}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Halaman terakhir</span>
|
||||
|
||||
260
resources/js/pages/admin/system/role/create.tsx
Normal file
260
resources/js/pages/admin/system/role/create.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Field, FieldError } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import roleRoutes from '@/routes/system/role';
|
||||
import type { Permission } from '@/types';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function RoleCreate({
|
||||
permissions,
|
||||
}: {
|
||||
permissions: Permission[];
|
||||
}) {
|
||||
const { data, setData, post, processing, errors } = useForm<{
|
||||
name: string;
|
||||
guard_name: string;
|
||||
permissions: number[];
|
||||
}>({
|
||||
name: '',
|
||||
guard_name: 'web',
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(roleRoutes.store().url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const groups: Record<string, Permission[]> = {};
|
||||
permissions.forEach((permission) => {
|
||||
const parts = permission.name.split(':');
|
||||
const groupName = parts.length > 1 ? parts[1] : 'Lainnya';
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(permission);
|
||||
});
|
||||
return groups;
|
||||
}, [permissions]);
|
||||
|
||||
const handleCheckboxChange = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setData('permissions', [...data.permissions, id]);
|
||||
} else {
|
||||
setData(
|
||||
'permissions',
|
||||
data.permissions.filter((pId) => pId !== id),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
const groupPermissionIds = groupedPermissions[groupName].map(
|
||||
(p) => p.id,
|
||||
);
|
||||
const allInGroupSelected = groupPermissionIds.every((id) =>
|
||||
data.permissions.includes(id),
|
||||
);
|
||||
|
||||
if (allInGroupSelected) {
|
||||
setData(
|
||||
'permissions',
|
||||
data.permissions.filter(
|
||||
(id) => !groupPermissionIds.includes(id),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const otherPermissions = data.permissions.filter(
|
||||
(id) => !groupPermissionIds.includes(id),
|
||||
);
|
||||
setData('permissions', [
|
||||
...otherPermissions,
|
||||
...groupPermissionIds,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setData(
|
||||
'permissions',
|
||||
permissions.map((p) => p.id),
|
||||
);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setData('permissions', []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Tambah Role" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Tambah Role
|
||||
</h1>
|
||||
</div>
|
||||
<Link href={roleRoutes.index().url}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="size-4" />
|
||||
Kembali
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<Card className="overflow-hidden border-none bg-card/50 shadow-lg backdrop-blur-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b bg-muted/30 p-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Field>
|
||||
<Label htmlFor="name" required>
|
||||
Nama
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={data.name}
|
||||
onChange={(e) =>
|
||||
setData('name', e.target.value)
|
||||
}
|
||||
autoComplete="off"
|
||||
placeholder="Contoh: Leader"
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.name}
|
||||
label="Nama"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor="guard_name" required>
|
||||
Guard
|
||||
</Label>
|
||||
<Input
|
||||
id="guard_name"
|
||||
name="guard_name"
|
||||
value={data.guard_name}
|
||||
onChange={(e) =>
|
||||
setData(
|
||||
'guard_name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.guard_name}
|
||||
label="Guard"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 p-6">
|
||||
{Object.entries(groupedPermissions).map(
|
||||
([groupName, groupPermissions]) => {
|
||||
const allInGroupSelected =
|
||||
groupPermissions.every((p) =>
|
||||
data.permissions.includes(p.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupName}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
|
||||
{groupName}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() =>
|
||||
toggleGroup(groupName)
|
||||
}
|
||||
>
|
||||
{allInGroupSelected
|
||||
? 'Lepas Semua'
|
||||
: 'Pilih Semua'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{groupPermissions.map(
|
||||
(permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className="group flex items-center space-x-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`permission-${permission.id}`}
|
||||
checked={data.permissions.includes(
|
||||
permission.id,
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked,
|
||||
) =>
|
||||
handleCheckboxChange(
|
||||
permission.id,
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
className="transition-transform active:scale-90"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`permission-${permission.id}`}
|
||||
className="cursor-pointer text-sm leading-none font-medium transition-colors group-hover:text-primary"
|
||||
>
|
||||
{
|
||||
permission.name.split(
|
||||
':',
|
||||
)[0]
|
||||
}
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? (
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoleCreate.layout = {
|
||||
breadcrumbs: [{ title: 'Sistem' }],
|
||||
};
|
||||
261
resources/js/pages/admin/system/role/edit.tsx
Normal file
261
resources/js/pages/admin/system/role/edit.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Field, FieldError } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import roleRoutes from '@/routes/system/role';
|
||||
import type { Permission, Role } from '@/types';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function RoleEdit({
|
||||
role,
|
||||
permissions,
|
||||
}: {
|
||||
role: Role;
|
||||
permissions: Permission[];
|
||||
}) {
|
||||
const { data, setData, put, processing, errors } = useForm<{
|
||||
name: string;
|
||||
guard_name: string;
|
||||
permissions: number[];
|
||||
}>({
|
||||
name: role.name,
|
||||
guard_name: role.guard_name,
|
||||
permissions: role.permissions?.map((p: Permission) => p.id) || [],
|
||||
});
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(roleRoutes.update(role.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const groups: Record<string, Permission[]> = {};
|
||||
permissions.forEach((permission) => {
|
||||
const parts = permission.name.split(':');
|
||||
const groupName = parts.length > 1 ? parts[1] : 'Lainnya';
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push(permission);
|
||||
});
|
||||
return groups;
|
||||
}, [permissions]);
|
||||
|
||||
const handleCheckboxChange = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setData('permissions', [...data.permissions, id]);
|
||||
} else {
|
||||
setData(
|
||||
'permissions',
|
||||
data.permissions.filter((pId) => pId !== id),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
const groupPermissionIds = groupedPermissions[groupName].map(
|
||||
(p) => p.id,
|
||||
);
|
||||
const allInGroupSelected = groupPermissionIds.every((id) =>
|
||||
data.permissions.includes(id),
|
||||
);
|
||||
|
||||
if (allInGroupSelected) {
|
||||
setData(
|
||||
'permissions',
|
||||
data.permissions.filter(
|
||||
(id) => !groupPermissionIds.includes(id),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const otherPermissions = data.permissions.filter(
|
||||
(id) => !groupPermissionIds.includes(id),
|
||||
);
|
||||
setData('permissions', [
|
||||
...otherPermissions,
|
||||
...groupPermissionIds,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setData(
|
||||
'permissions',
|
||||
permissions.map((p) => p.id),
|
||||
);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setData('permissions', []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Ubah Role" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Ubah Role
|
||||
</h1>
|
||||
</div>
|
||||
<Link href={roleRoutes.index().url}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="size-4" />
|
||||
Kembali
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<Card className="overflow-hidden border-none bg-card/50 shadow-lg backdrop-blur-sm">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b bg-muted/30 p-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Field>
|
||||
<Label htmlFor="name" required>
|
||||
Nama
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={data.name}
|
||||
onChange={(e) =>
|
||||
setData('name', e.target.value)
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.name}
|
||||
label="Nama"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor="guard_name" required>
|
||||
Guard
|
||||
</Label>
|
||||
<Input
|
||||
id="guard_name"
|
||||
name="guard_name"
|
||||
value={data.guard_name}
|
||||
onChange={(e) =>
|
||||
setData(
|
||||
'guard_name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldError
|
||||
error={errors.guard_name}
|
||||
label="Guard"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 p-6">
|
||||
{Object.entries(groupedPermissions).map(
|
||||
([groupName, groupPermissions]) => {
|
||||
const allInGroupSelected =
|
||||
groupPermissions.every((p) =>
|
||||
data.permissions.includes(p.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupName}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
|
||||
{groupName}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() =>
|
||||
toggleGroup(groupName)
|
||||
}
|
||||
>
|
||||
{allInGroupSelected
|
||||
? 'Lepas Semua'
|
||||
: 'Pilih Semua'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{groupPermissions.map(
|
||||
(permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className="group flex items-center space-x-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`permission-${permission.id}`}
|
||||
checked={data.permissions.includes(
|
||||
permission.id,
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked,
|
||||
) =>
|
||||
handleCheckboxChange(
|
||||
permission.id,
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
className="transition-transform active:scale-90"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`permission-${permission.id}`}
|
||||
className="cursor-pointer text-sm leading-none font-medium transition-colors group-hover:text-primary"
|
||||
>
|
||||
{
|
||||
permission.name.split(
|
||||
':',
|
||||
)[0]
|
||||
}
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? (
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoleEdit.layout = {
|
||||
breadcrumbs: [{ title: 'Sistem' }],
|
||||
};
|
||||
60
resources/js/pages/admin/system/role/hooks/use-role-index.ts
Normal file
60
resources/js/pages/admin/system/role/hooks/use-role-index.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import roleRoutes from '@/routes/system/role';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
export function useRoleIndex() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
|
||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const onDelete = (role: Role) => {
|
||||
setRoleToDelete(role);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (roleToDelete) {
|
||||
router.delete(roleRoutes.destroy(roleToDelete.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setRoleToDelete(null);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
router.post(roleRoutes.bulkDestroy().url, {
|
||||
ids: rowsToDelete.map((row: any) => row.id),
|
||||
_method: 'DELETE'
|
||||
}, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setRowsToDelete([]);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
roleToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
}
|
||||
114
resources/js/pages/admin/system/role/index.tsx
Normal file
114
resources/js/pages/admin/system/role/index.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import roleRoutes from '@/routes/system/role';
|
||||
import type { Role } from '@/types';
|
||||
import { useRoleIndex } from './hooks/use-role-index';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
|
||||
export default function RoleIndex({ roles }: { roles: Role[] }) {
|
||||
const { hasPermission } = usePermission();
|
||||
const {
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
roleToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
} = useRoleIndex();
|
||||
|
||||
const columns = getColumns({ onDelete }).filter(
|
||||
(col) => col.id !== 'actions' || hasPermission('Edit:Role') || hasPermission('Delete:Role')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Role & Izin Akses" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Role & Izin Akses</h1>
|
||||
</div>
|
||||
{hasPermission('Create:Role') && (
|
||||
<Link href={roleRoutes.create().url}>
|
||||
<Button className="gap-2">
|
||||
<Plus className="size-4" />
|
||||
Tambah
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||
<CardContent className="p-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={roles}
|
||||
showSelection={hasPermission('DeleteAny:Role')}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
bulkActions={
|
||||
hasPermission('DeleteAny:Role')
|
||||
? [
|
||||
{
|
||||
label: 'Hapus Terpilih',
|
||||
onClick: (rows) => {
|
||||
setRowsToDelete(rows);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
},
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Single Delete Confirmation */}
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
title="Hapus role?"
|
||||
description={
|
||||
<>
|
||||
Tindakan ini tidak dapat dibatalkan. Role <strong>{roleToDelete?.name}</strong> akan dihapus secara permanen.
|
||||
</>
|
||||
}
|
||||
onDelete={confirmDelete}
|
||||
/>
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<DeleteConfirmation
|
||||
isOpen={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
title={`Hapus ${rowsToDelete.length} role?`}
|
||||
description={
|
||||
<>
|
||||
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
|
||||
</>
|
||||
}
|
||||
onDelete={confirmBulkDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoleIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Sistem',
|
||||
},
|
||||
],
|
||||
};
|
||||
114
resources/js/pages/admin/system/role/partials/columns.tsx
Normal file
114
resources/js/pages/admin/system/role/partials/columns.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import roleRoutes from '@/routes/system/role';
|
||||
import type { Role } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ColumnProps {
|
||||
onDelete: (role: Role) => void;
|
||||
}
|
||||
|
||||
export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Role>[] => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Nama" />
|
||||
),
|
||||
meta: { title: 'Nama' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'permissions',
|
||||
header: 'Izin Akses',
|
||||
meta: { title: 'Izin Akses' },
|
||||
cell: ({ row }) => {
|
||||
const permissions = row.original.permissions || [];
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
Tidak ada izin akses
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-md flex-wrap gap-1">
|
||||
{permissions.map((p) => (
|
||||
<Badge
|
||||
key={p.id}
|
||||
variant="secondary"
|
||||
className="px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'guard_name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Guard" />
|
||||
),
|
||||
meta: { title: 'Guard' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Aksi',
|
||||
cell: ({ row }) => {
|
||||
const role = row.original;
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPermission('Edit:Role') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={roleRoutes.edit(role.id).url}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-yellow-600 hover:bg-yellow-50 hover:text-yellow-700 dark:hover:bg-yellow-950/20"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ubah</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasPermission('Delete:Role') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/20"
|
||||
onClick={() => onDelete(role)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hapus</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: 'Aksi' },
|
||||
},
|
||||
];
|
||||
@ -10,3 +10,5 @@ export type * from './general-setting';
|
||||
export type * from './activity';
|
||||
export type * from './system-log';
|
||||
export type * from './dashboard';
|
||||
export type * from './role';
|
||||
export type * from './permission';
|
||||
|
||||
7
resources/js/types/permission.ts
Normal file
7
resources/js/types/permission.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
guard_name: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
10
resources/js/types/role.ts
Normal file
10
resources/js/types/role.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { Permission } from './permission';
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
guard_name: string;
|
||||
permissions?: Permission[];
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Admin\System\ActivityLogController;
|
||||
use App\Http\Controllers\Admin\System\GeneralSettingController;
|
||||
use App\Http\Controllers\Admin\System\LogController;
|
||||
use App\Http\Controllers\Admin\System\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
@ -10,6 +11,16 @@
|
||||
Route::get('logs', [LogController::class, 'index'])->name('system.logs.index')->middleware('can:View:Log');
|
||||
Route::get('activity-logs', [ActivityLogController::class, 'index'])->name('system.activity-logs.index')->middleware('can:View:Activity');
|
||||
|
||||
Route::prefix('role')->controller(RoleController::class)->group(function () {
|
||||
Route::get('/', 'index')->name('system.role.index')->middleware('can:View:Role');
|
||||
Route::get('/create', 'create')->name('system.role.create')->middleware('can:Create:Role');
|
||||
Route::post('/', 'store')->name('system.role.store')->middleware('can:Create:Role');
|
||||
Route::get('/{role}/edit', 'edit')->name('system.role.edit')->middleware('can:Edit:Role');
|
||||
Route::put('/{role}', 'update')->name('system.role.update')->middleware('can:Edit:Role');
|
||||
Route::delete('/{role}', 'destroy')->name('system.role.destroy')->middleware('can:Delete:Role');
|
||||
Route::post('/bulk-destroy', 'bulkDestroy')->name('system.role.bulk-destroy')->middleware('can:DeleteAny:Role');
|
||||
});
|
||||
|
||||
Route::controller(GeneralSettingController::class)->prefix('settings')->group(function () {
|
||||
Route::get('/', 'index')->name('system.settings.index')->middleware('can:View:Setting');
|
||||
Route::post('/', 'update')->name('system.settings.update')->middleware('can:Edit:Setting');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user