Add category management routes, permissions, and UI components. Integrate new permissions for categories in the Permission and Role enums, and update the sidebar to include category navigation. Enhance the composer configuration by adding the Spatie Laravel Sluggable package for slug generation.

This commit is contained in:
Yoga Pangestu 2026-06-10 02:06:14 +07:00
parent 370398412b
commit 432bbd110a
14 changed files with 620 additions and 25 deletions

View File

@ -17,6 +17,11 @@ enum Permission: string
case EMPLOYEES_RESET_PASSWORD = 'employees.reset-password';
case EMPLOYEES_TOGGLE_STATUS = 'employees.toggle-status';
case CATEGORIES_VIEW = 'categories.view';
case CATEGORIES_CREATE = 'categories.create';
case CATEGORIES_UPDATE = 'categories.update';
case CATEGORIES_DELETE = 'categories.delete';
public function label(): string
{
return match ($this) {
@ -28,6 +33,11 @@ public function label(): string
self::EMPLOYEES_DELETE => 'Hapus Pegawai',
self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai',
self::EMPLOYEES_TOGGLE_STATUS => 'Ubah Status Pegawai',
self::CATEGORIES_VIEW => 'Lihat Kategori',
self::CATEGORIES_CREATE => 'Tambah Kategori',
self::CATEGORIES_UPDATE => 'Ubah Kategori',
self::CATEGORIES_DELETE => 'Hapus Kategori',
};
}
@ -37,6 +47,8 @@ public function group(): string
self::DASHBOARD_VIEW => 'Umum',
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
self::CATEGORIES_DELETE => 'Kategori',
};
}

View File

@ -44,6 +44,10 @@ public function permissions(): array
Permission::EMPLOYEES_DELETE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -52,14 +56,23 @@ public function permissions(): array
Permission::EMPLOYEES_UPDATE,
Permission::EMPLOYEES_RESET_PASSWORD,
Permission::EMPLOYEES_TOGGLE_STATUS,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::CATEGORIES_VIEW,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CategoryRequest;
use App\Models\Category;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
use ParsesDataTableQuery;
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$search = $tableQuery['search'];
$sort = $tableQuery['sort'];
$direction = $tableQuery['direction'];
$query = Category::query()
->when($search !== '', function (Builder $query) use ($search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%");
});
});
$this->applySorting($query, $sort, $direction);
$categories = $query
->paginate(10)
->withQueryString()
->through(fn (Category $category) => $this->transformCategory($category));
return Inertia::render('admin/categories/Index', [
'categories' => $categories,
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(CategoryRequest $request): RedirectResponse
{
Category::create($request->validated());
Inertia::flash('success', 'Kategori berhasil ditambahkan.');
return redirect()->route('admin.master.categories.index');
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
$validated = $request->validated();
$category->name = $validated['name'];
$category->save();
Inertia::flash('success', 'Kategori berhasil diperbarui.');
return redirect()->route('admin.master.categories.index');
}
public function destroy(Category $category): RedirectResponse
{
$category->delete();
Inertia::flash('success', 'Kategori berhasil dihapus.');
return redirect()->route('admin.master.categories.index');
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'slug'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
/**
* @return array<string, mixed>
*/
private function transformCategory(Category $category): array
{
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class CategoryRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::CATEGORIES_CREATE
: Permission::CATEGORIES_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
];
}
}

15
app/Models/Category.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Sluggable\Attributes\Sluggable;
#[Guarded(['id'])]
#[Sluggable(from: 'name', to: 'slug')]
class Category extends Model
{
use SoftDeletes;
}

View File

@ -14,7 +14,8 @@
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"spatie/laravel-permission": "^8.0"
"spatie/laravel-permission": "^8.0",
"spatie/laravel-sluggable": "^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.24",

80
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "214f5e51f5d046a46a01d066adf96929",
"content-hash": "dd4192a3bb321ccbee15386b55196fc5",
"packages": [
{
"name": "brick/math",
@ -3636,6 +3636,84 @@
],
"time": "2026-05-30T19:30:22+00:00"
},
{
"name": "spatie/laravel-sluggable",
"version": "4.0.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-sluggable.git",
"reference": "82a69be1ef661ce2ff38242b271457ef0b9611dd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-sluggable/zipball/82a69be1ef661ce2ff38242b271457ef0b9611dd",
"reference": "82a69be1ef661ce2ff38242b271457ef0b9611dd",
"shasum": ""
},
"require": {
"illuminate/database": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"php": "^8.3"
},
"require-dev": {
"larastan/larastan": "^3.0",
"laravel/pint": "^1.24",
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^4.0",
"spatie/laravel-translatable": "^6.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"SelfHealing": "Spatie\\Sluggable\\Facades\\SelfHealing"
},
"providers": [
"Spatie\\Sluggable\\SluggableServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\Sluggable\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Generate slugs when saving Eloquent models",
"homepage": "https://github.com/spatie/laravel-sluggable",
"keywords": [
"eloquent",
"laravel",
"laravel-sluggable",
"self-healing",
"slug",
"slugs",
"spatie",
"translatable"
],
"support": {
"issues": "https://github.com/spatie/laravel-sluggable/issues",
"source": "https://github.com/spatie/laravel-sluggable/tree/4.0.2"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-04-30T17:28:09+00:00"
},
{
"name": "symfony/clock",
"version": "v8.1.0",

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->string('slug', 50)->unique();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { LayoutDashboard, Users } from '@lucide/vue';
import { FolderTree, LayoutDashboard, Users } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -21,6 +21,7 @@ const { can } = useCan();
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
</script>
<template>
@ -54,6 +55,21 @@ const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employee
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="can('categories.view')">
<SidebarGroupLabel>Master</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="Kategori" :is-active="isCategoriesActive">
<Link href="/admin/master/categories">
<FolderTree />
<span>Kategori</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="can('employees.view')">
<SidebarGroupLabel>HR</SidebarGroupLabel>
<SidebarGroupContent>

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { CategoryFormData, CategoryListItem } from '@/types/category';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
category?: CategoryListItem | null;
}>();
const isEditing = computed(() => props.category != null);
const form = useForm<CategoryFormData>({
name: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(category: CategoryListItem | null | undefined) {
resetForm();
if (!category) {
return;
}
form.name = category.name;
}
watch(
() => props.category,
(category) => {
populateForm(category);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.category);
} else {
resetForm();
}
});
function submit() {
const options = {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
if (isEditing.value && props.category) {
form.put(`/admin/master/categories/${props.category.id}`, options);
} else {
form.post('/admin/master/categories', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Kategori' : 'Tambah Kategori' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="category-name" required>Nama</FieldLabel>
<Input id="category-name" v-model="form.name" type="text" placeholder="Nama kategori"
autofocus />
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,24 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/categories/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { CategoryListItem } from '@/types/category';
export function createColumns(onEdit: (category: CategoryListItem) => void): ColumnDef<CategoryListItem>[] {
return [
{
accessorKey: 'name',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'name' }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
category: row.original,
onEdit: () => onEdit(row.original),
}),
},
];
}

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import CategoryFormModal from '@/components/categories/CategoryFormModal.vue';
import { createColumns } from '@/components/categories/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 { CategoryListItem, PaginatedCategories } from '@/types/category';
import type { DataTableSort } from '@/types/data-table';
const props = defineProps<{
categories: PaginatedCategories;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const editingCategory = ref<CategoryListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/mastercategories',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(openEditModal));
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.categories.current_page,
perPage: props.categories.per_page,
lastPage: props.categories.last_page,
total: props.categories.total,
}));
function openCreateModal() {
editingCategory.value = null;
formModalOpen.value = true;
}
function openEditModal(category: CategoryListItem) {
editingCategory.value = category;
formModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Kategori" />
<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">
Kategori
</h2>
</div>
<Button v-if="can('categories.create')" class="shrink-0 self-start sm:self-center" @click="openCreateModal">
<Plus class="size-4" />
Tambah
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="categories.data" :pagination="pagination"
:pagination-links="categories.links" :sort="currentSort" @sort-change="setSort"
@filters-reset="resetFilters" />
</CardContent>
</Card>
<CategoryFormModal v-if="can('categories.create') || can('categories.update')" v-model:open="formModalOpen"
:category="editingCategory" />
</AdminLayout>
</template>

View File

@ -0,0 +1,29 @@
export type CategoryListItem = {
id: number;
name: string;
slug: string;
};
export type CategoryFormData = {
name: string;
slug: string;
};
export type CategoryFilters = {
search: string;
sort?: string;
direction?: 'asc' | 'desc' | null;
};
export type PaginatedCategories = {
data: CategoryListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};

View File

@ -1,6 +1,7 @@
<?php
use App\Enums\Permission;
use App\Http\Controllers\Admin\CategoryController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Auth\LoginController;
@ -21,29 +22,62 @@
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::prefix('master')->name('master.')
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
->group(function () {
Route::prefix('categories')->name('categories.')
->middleware('permission:'.Permission::CATEGORIES_VIEW->value)
->group(function () {
Route::get('/', [CategoryController::class, 'index'])->name('index');
Route::post('/', [CategoryController::class, 'store'])
->middleware('permission:'.Permission::CATEGORIES_CREATE->value)
->name('store');
Route::put('{category}', [CategoryController::class, 'update'])
->middleware('permission:'.Permission::CATEGORIES_UPDATE->value)
->name('update');
Route::delete('{category}', [CategoryController::class, 'destroy'])
->middleware('permission:'.Permission::CATEGORIES_DELETE->value)
->name('destroy');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('employees.reset-password');
Route::patch('employees/{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('employees.toggle-status');
Route::get('employees/create', [EmployeeController::class, 'create'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.create');
Route::post('employees', [EmployeeController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.store');
Route::get('employees/{user}/edit', [EmployeeController::class, 'edit'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.edit');
Route::put('employees/{user}', [EmployeeController::class, 'update'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.update');
Route::delete('employees/{user}', [EmployeeController::class, 'destroy'])
->middleware('permission:'.Permission::EMPLOYEES_DELETE->value)
->name('employees.destroy');
Route::get('employees', [EmployeeController::class, 'index'])->name('employees.index');
Route::prefix('employees')->name('employees.')
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)
->group(function () {
Route::post('{user}/reset-password', [EmployeeController::class, 'resetPassword'])
->middleware('permission:'.Permission::EMPLOYEES_RESET_PASSWORD->value)
->name('employees.reset-password');
Route::patch('{user}/toggle-status', [EmployeeController::class, 'toggleStatus'])
->middleware('permission:'.Permission::EMPLOYEES_TOGGLE_STATUS->value)
->name('employees.toggle-status');
Route::get('create', [EmployeeController::class, 'create'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.create');
Route::post('employees', [EmployeeController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('employees.store');
Route::get('{user}/edit', [EmployeeController::class, 'edit'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.edit');
Route::put('{user}', [EmployeeController::class, 'update'])
->middleware('permission:'.Permission::EMPLOYEES_UPDATE->value)
->name('employees.update');
Route::delete('{user}', [EmployeeController::class, 'destroy'])
->middleware('permission:'.Permission::EMPLOYEES_DELETE->value)
->name('employees.destroy');
Route::get('/', [EmployeeController::class, 'index'])->name('employees.index');
});
});
});
});