feat: implement category management with CRUD functionality and integrate sluggable for SEO-friendly URLs

- Added CategoryController for handling category operations.
- Created CategoryRequest for validation of category data.
- Introduced CategoryService for business logic related to categories.
- Implemented sluggable functionality in the Category model for automatic slug generation.
- Developed UI components for category management, including a data table and dialogs for creating and editing categories.
- Updated routes to include resourceful routes for categories.
This commit is contained in:
Yoga Pangestu 2026-07-29 01:11:53 +07:00
parent 61feb43fa2
commit e93fd181ee
18 changed files with 1257 additions and 27 deletions

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\CategoryRequest;
use App\Models\Category;
use App\Services\Admin\Master\CategoryService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
public function __construct(
private CategoryService $service
) {}
public function index(): Response
{
return Inertia::render('admin/master/category/index', [
'categories' => $this->service->getAll(),
]);
}
public function store(CategoryRequest $request): RedirectResponse
{
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']);
return to_route('admin.master.categories.index');
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
$this->service->update($category, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil diperbarui.']);
return to_route('admin.master.categories.index');
}
public function destroy(Category $category): RedirectResponse
{
$this->service->delete($category);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']);
return to_route('admin.master.categories.index');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Master;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$category = $this->route('category');
return [
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($category)],
];
}
public function attributes(): array
{
return [
'name' => 'nama',
];
}
}

View File

@ -7,8 +7,10 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Sluggable\Attributes\Sluggable;
#[Guarded(['id'])]
#[Sluggable(from: 'name', to: 'slug')]
class Category extends Model
{
use HasFactory, SoftDeletes;

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Admin\Master;
use App\Models\Category;
use Illuminate\Database\Eloquent\Collection;
class CategoryService
{
public function getAll(): Collection
{
return Category::latest()->get();
}
public function create(array $data): Category
{
return Category::create($data);
}
public function update(Category $category, array $data): Category
{
$category->update($data);
return $category;
}
public function delete(Category $category): bool
{
return $category->delete();
}
}

View File

@ -16,7 +16,8 @@
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"spatie/laravel-permission": "^8.3"
"spatie/laravel-permission": "^8.3",
"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": "06614299e9ee6d35887901c6b34eb24b",
"content-hash": "bf685f2e2b1d13f4dfba5042cdd5af8b",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -4271,6 +4271,84 @@
],
"time": "2026-07-03T15:36:01+00:00"
},
{
"name": "spatie/laravel-sluggable",
"version": "4.0.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-sluggable.git",
"reference": "572933c61b70103be4b95fe7674070bb60243254"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-sluggable/zipball/572933c61b70103be4b95fe7674070bb60243254",
"reference": "572933c61b70103be4b95fe7674070bb60243254",
"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.3"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-07-28T13:16:49+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.3.0",

View File

@ -9,10 +9,10 @@ class CategoryFactory extends Factory
{
public function definition(): array
{
$name = fake()->unique()->word();
$name = fake()->unique()->words(2, true);
return [
'name' => $name,
'name' => ucfirst($name),
'slug' => Str::slug($name),
];
}

View File

@ -0,0 +1,14 @@
<?php
namespace Database\Seeders;
use App\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
Category::factory()->count(1000)->create();
}
}

View File

@ -16,6 +16,7 @@ public function run(): void
{
$this->call([
UserSeeder::class,
CategorySeeder::class,
]);
}
}

View File

@ -9,7 +9,6 @@ import {
CalendarDays,
ClipboardCheck,
DollarSign,
FileText,
HandCoins,
LayoutGrid,
Package,
@ -36,6 +35,7 @@ import {
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
@ -52,7 +52,7 @@ const analisaItem: NavMenuItem = {
};
const masterItems: NavMenuItem[] = [
{ title: 'Kategori', href: '#', icon: Tags },
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
{ title: 'Produk', href: '#', icon: Package },
{ title: 'Bahan Baku', href: '#', icon: Boxes },
{ title: 'Supplier', href: '#', icon: Truck },
@ -64,7 +64,6 @@ const kelolaItems: NavMenuItem[] = [
{ title: 'Cutting', href: '#', icon: Scissors },
{ title: 'Restock', href: '#', icon: RefreshCw },
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
{ title: 'Pesanan', href: '#', icon: FileText },
];
const keuanganItems: NavMenuItem[] = [

View File

@ -0,0 +1,59 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void;
loading?: boolean;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Konfirmasi',
cancelLabel = 'Batal',
variant = 'destructive',
onConfirm,
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</Button>
<Button
variant={variant}
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,371 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
import { GripVertical } from 'lucide-react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
searchPlaceholder?: string;
emptyText?: string;
onReorder?: (items: TData[]) => void;
getRowId?: (item: TData) => string | number;
}
const DragHandleContext = React.createContext<{
listeners?: Record<string, unknown>;
attributes?: Record<string, string>;
}>({});
export function DragHandleTrigger() {
const { listeners, attributes } = React.useContext(DragHandleContext);
return (
<Button
variant="ghost"
size="icon"
className="-ml-2 cursor-grab active:cursor-grabbing"
{...listeners}
{...attributes}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</Button>
);
}
function SortableTableRow({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : undefined,
};
return (
<DragHandleContext.Provider value={{ listeners, attributes }}>
<TableRow
ref={setNodeRef}
style={style}
data-state={isDragging ? 'dragging' : undefined}
>
{children}
</TableRow>
</DragHandleContext.Provider>
);
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = 'Cari...',
emptyText = 'Tidak ada data.',
onReorder,
getRowId,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([]);
const isSortable = !!onReorder && !!getRowId;
const visibleColumns = isSortable
? [
{
id: 'drag',
header: '',
cell: () => <DragHandleTrigger />,
meta: {
className: 'w-[40px]',
headerClassName: 'w-[40px]',
},
} as ColumnDef<TData, TValue>,
...columns,
]
: columns;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor),
);
const table = useReactTable({
data,
columns: visibleColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
const itemIds = React.useMemo(
() => data.map((item) => String(getRowId ? getRowId(item) : '')),
[data, getRowId],
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = itemIds.indexOf(String(active.id));
const newIndex = itemIds.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) {
return;
}
const reordered = [...data];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
onReorder?.(reordered);
}
return (
<>
{searchKey && (
<Input
placeholder={searchPlaceholder}
value={
(table
.getColumn(searchKey)
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn(searchKey)
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
)}
<Card className="bg-sidebar p-0">
<CardContent className="p-0">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={
(
header.column.columnDef
.meta as {
headerClassName?: string;
}
)?.headerClassName
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
isSortable ? (
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext
items={itemIds}
strategy={
verticalListSortingStrategy
}
>
{table
.getRowModel()
.rows.map((row) => (
<SortableTableRow
key={row.id}
id={String(
getRowId!(
row.original,
),
)}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)
?.className
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</SortableTableRow>
))}
</SortableContext>
</DndContext>
) : (
table
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)?.className
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
)
) : (
<TableRow>
<TableCell
colSpan={visibleColumns.length}
className="h-24 text-center"
>
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Halaman {table.getState().pagination.pageIndex + 1} dari{' '}
{table.getPageCount()}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</>
);
}

View File

@ -1,9 +1,9 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
@ -54,28 +54,24 @@ function DialogContent({
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-xs/relaxed text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@ -87,7 +83,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1", className)}
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
@ -127,7 +123,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-sm font-medium", className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
@ -140,10 +136,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-xs/relaxed text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,114 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export type Category = {
id: number;
name: string;
};
type CreateColumnsParams = {
handleEdit: (category: Category) => void;
handleDeleteClick: (category: Category) => void;
};
export function createCategoryColumns(
params: CreateColumnsParams,
): ColumnDef<Category>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('name') as string}
</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const category = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(category)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(category)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -0,0 +1,206 @@
import { Form, Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { destroy, index as categoryIndex, store, update } from '@/routes/admin/master/categories';
import { createCategoryColumns } from './columns';
import type { Category } from './columns';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
type Props = {
categories: Category[];
};
export default function CategoryIndex({ categories }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createCategoryColumns({
handleEdit: (category) => setEditing(category),
handleDeleteClick: (category) => setDeleting(category),
});
return (
<>
<Head title="Kategori" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Kategori
</h2>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
<DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Tambah Kategori</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama kategori"
/>
<InputError message={errors.name} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
Batal
</Button>
<Button
type='submit'
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={categories}
searchKey="name"
searchPlaceholder="Cari kategori..."
emptyText="Belum ada data kategori."
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
>
<DialogContent>
{editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
{({ errors, processing }) => {
return (
<>
<DialogHeader>
<DialogTitle>Edit Kategori</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
<Input
id="edit-name"
name="name"
placeholder="Masukkan nama kategori"
defaultValue={editing.name}
/>
<InputError message={errors.name} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditing(null);
}}
>
Batal
</Button>
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</DialogFooter>
</>
);
}}
</Form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Kategori"
description={`Apakah Anda yakin ingin menghapus kategori "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Hapus"
onConfirm={handleDelete}
/>
</div>
</>
);
}
CategoryIndex.layout = {
breadcrumbs: [
{
title: 'Master',
href: categoryIndex(),
},
{
title: 'Kategori',
href: categoryIndex(),
},
],
};

View File

@ -1,11 +1,16 @@
<?php
use App\Http\Controllers\Admin\Master\CategoryController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'dashboard')->name('dashboard');
Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']);
});
});
require __DIR__.'/settings.php';

View File

@ -0,0 +1,160 @@
<?php
use App\Models\Category;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
test('guests are redirected to the login page', function () {
$response = $this->get(route('admin.master.categories.index'));
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the category index page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.master.categories.index'));
$response->assertOk();
});
test('category index page displays categories', function () {
$user = User::factory()->create();
$this->actingAs($user);
$categories = Category::factory()->count(3)->create();
$response = $this->get(route('admin.master.categories.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/category/index')
->has('categories', 3)
);
});
test('category can be created', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.categories.store'), [
'name' => 'Kategori Test',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.categories.index'));
$this->assertDatabaseHas('categories', [
'name' => 'Kategori Test',
'slug' => 'kategori-test',
]);
});
test('category name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.categories.store'), [
'name' => '',
]);
$response->assertSessionHasErrors('name');
});
test('category name must not exceed 100 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.categories.store'), [
'name' => str_repeat('a', 101),
]);
$response->assertSessionHasErrors('name');
});
test('category name must be unique', function () {
$user = User::factory()->create();
$this->actingAs($user);
Category::factory()->create(['name' => 'Existing Category']);
$response = $this->post(route('admin.master.categories.store'), [
'name' => 'Existing Category',
]);
$response->assertSessionHasErrors('name');
});
test('category slug is automatically generated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.categories.store'), [
'name' => 'Kategori Otomatis',
]);
$this->assertDatabaseHas('categories', [
'name' => 'Kategori Otomatis',
'slug' => 'kategori-otomatis',
]);
});
test('category can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$category = Category::factory()->create();
$response = $this->put(route('admin.master.categories.update', $category), [
'name' => 'Kategori Updated',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.categories.index'));
$category->refresh();
expect($category->name)->toBe('Kategori Updated');
expect($category->slug)->toBe('kategori-updated');
});
test('category name can be updated to itself', function () {
$user = User::factory()->create();
$this->actingAs($user);
$category = Category::factory()->create(['name' => 'My Category']);
$response = $this->put(route('admin.master.categories.update', $category), [
'name' => 'My Category',
]);
$response->assertSessionHasNoErrors();
});
test('category update name must be unique excluding itself', function () {
$user = User::factory()->create();
$this->actingAs($user);
$category = Category::factory()->create(['name' => 'First']);
Category::factory()->create(['name' => 'Second']);
$response = $this->put(route('admin.master.categories.update', $category), [
'name' => 'Second',
]);
$response->assertSessionHasErrors('name');
});
test('category can be deleted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$category = Category::factory()->create();
$response = $this->delete(route('admin.master.categories.destroy', $category));
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.categories.index'));
$this->assertSoftDeleted('categories', ['id' => $category->id]);
});