diff --git a/app/Http/Controllers/Admin/Master/CategoryController.php b/app/Http/Controllers/Admin/Master/CategoryController.php new file mode 100644 index 0000000..2d59475 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/CategoryController.php @@ -0,0 +1,52 @@ + $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'); + } +} diff --git a/app/Http/Requests/Admin/Master/CategoryRequest.php b/app/Http/Requests/Admin/Master/CategoryRequest.php new file mode 100644 index 0000000..5b7b22e --- /dev/null +++ b/app/Http/Requests/Admin/Master/CategoryRequest.php @@ -0,0 +1,30 @@ +route('category'); + + return [ + 'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($category)], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'nama', + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index d79b935..080a51e 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -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; diff --git a/app/Services/Admin/Master/CategoryService.php b/app/Services/Admin/Master/CategoryService.php new file mode 100644 index 0000000..4974040 --- /dev/null +++ b/app/Services/Admin/Master/CategoryService.php @@ -0,0 +1,31 @@ +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(); + } +} diff --git a/composer.json b/composer.json index 6ce1808..6645e7c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 1c0822b..563d633 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php index 9dbc4a3..053a445 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -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), ]; } diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..0d26d3f --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,14 @@ +count(1000)->create(); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b9c7377..ca0d0c6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ public function run(): void { $this->call([ UserSeeder::class, + CategorySeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 40107e0..cf3ef02 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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[] = [ diff --git a/resources/js/components/confirm-dialog.tsx b/resources/js/components/confirm-dialog.tsx new file mode 100644 index 0000000..f52a26e --- /dev/null +++ b/resources/js/components/confirm-dialog.tsx @@ -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 ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/resources/js/components/data-table.tsx b/resources/js/components/data-table.tsx new file mode 100644 index 0000000..98b5dbb --- /dev/null +++ b/resources/js/components/data-table.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; + searchKey?: string; + searchPlaceholder?: string; + emptyText?: string; + onReorder?: (items: TData[]) => void; + getRowId?: (item: TData) => string | number; +} + +const DragHandleContext = React.createContext<{ + listeners?: Record; + attributes?: Record; +}>({}); + +export function DragHandleTrigger() { + const { listeners, attributes } = React.useContext(DragHandleContext); + + return ( + + ); +} + +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 ( + + + {children} + + + ); +} + +export function DataTable({ + columns, + data, + searchKey, + searchPlaceholder = 'Cari...', + emptyText = 'Tidak ada data.', + onReorder, + getRowId, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = + React.useState([]); + + const isSortable = !!onReorder && !!getRowId; + + const visibleColumns = isSortable + ? [ + { + id: 'drag', + header: '', + cell: () => , + meta: { + className: 'w-[40px]', + headerClassName: 'w-[40px]', + }, + } as ColumnDef, + ...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 && ( + + table + .getColumn(searchKey) + ?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + )} + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + isSortable ? ( + + + {table + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext(), + )} + + ))} + + ))} + + + ) : ( + table + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext(), + )} + + ))} + + )) + ) + ) : ( + + + {emptyText} + + + )} + +
+
+
+ +
+
+ Halaman {table.getState().pagination.pageIndex + 1} dari{' '} + {table.getPageCount()} +
+
+ + +
+
+ + ); +} diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx index 3789931..e5f0a96 100644 --- a/resources/js/components/ui/dialog.tsx +++ b/resources/js/components/ui/dialog.tsx @@ -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({ + {children} {showCloseButton && ( - - + + + Close )} @@ -87,7 +83,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) @@ -127,7 +123,7 @@ function DialogTitle({ return ( ) @@ -140,10 +136,7 @@ function DialogDescription({ return ( ) diff --git a/resources/js/components/ui/table.tsx b/resources/js/components/ui/table.tsx new file mode 100644 index 0000000..fcfa0a2 --- /dev/null +++ b/resources/js/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/resources/js/pages/admin/master/category/columns.tsx b/resources/js/pages/admin/master/category/columns.tsx new file mode 100644 index 0000000..11b0229 --- /dev/null +++ b/resources/js/pages/admin/master/category/columns.tsx @@ -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[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('name') as string} + + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const category = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/master/category/index.tsx b/resources/js/pages/admin/master/category/index.tsx new file mode 100644 index 0000000..925331a --- /dev/null +++ b/resources/js/pages/admin/master/category/index.tsx @@ -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(null); + const [deleting, setDeleting] = useState(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 ( + <> + + +
+
+
+

+ Kategori +

+
+ + + + +
setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Kategori + +
+
+ + + +
+
+ + + + + + ); + }} +
+
+
+
+ + + + { + if (!open) { + setEditing(null); + } + }} + > + + {editing && ( +
setEditing(null)}> + {({ errors, processing }) => { + + return ( + <> + + Edit Kategori + +
+
+ + + +
+
+ + + + + + ); + }} +
+ )} +
+
+ + { + 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} + /> +
+ + ); +} + +CategoryIndex.layout = { + breadcrumbs: [ + { + title: 'Master', + href: categoryIndex(), + }, + { + title: 'Kategori', + href: categoryIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 2085b21..6d9aff6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,16 @@ 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'; diff --git a/tests/Feature/Admin/Master/CategoryTest.php b/tests/Feature/Admin/Master/CategoryTest.php new file mode 100644 index 0000000..f4d7d7f --- /dev/null +++ b/tests/Feature/Admin/Master/CategoryTest.php @@ -0,0 +1,160 @@ +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]); +});