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:
parent
61feb43fa2
commit
e93fd181ee
52
app/Http/Controllers/Admin/Master/CategoryController.php
Normal file
52
app/Http/Controllers/Admin/Master/CategoryController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal file
30
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,10 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\Sluggable\Attributes\Sluggable;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
|
#[Sluggable(from: 'name', to: 'slug')]
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|||||||
31
app/Services/Admin/Master/CategoryService.php
Normal file
31
app/Services/Admin/Master/CategoryService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,8 @@
|
|||||||
"laravel/framework": "^13.17",
|
"laravel/framework": "^13.17",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
"spatie/laravel-permission": "^8.3"
|
"spatie/laravel-permission": "^8.3",
|
||||||
|
"spatie/laravel-sluggable": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.24",
|
"fakerphp/faker": "^1.24",
|
||||||
|
|||||||
80
composer.lock
generated
80
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "06614299e9ee6d35887901c6b34eb24b",
|
"content-hash": "bf685f2e2b1d13f4dfba5042cdd5af8b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@ -4271,6 +4271,84 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-03T15:36:01+00:00"
|
"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",
|
"name": "spomky-labs/cbor-php",
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
|
|||||||
@ -9,10 +9,10 @@ class CategoryFactory extends Factory
|
|||||||
{
|
{
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$name = fake()->unique()->word();
|
$name = fake()->unique()->words(2, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $name,
|
'name' => ucfirst($name),
|
||||||
'slug' => Str::slug($name),
|
'slug' => Str::slug($name),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
14
database/seeders/CategorySeeder.php
Normal file
14
database/seeders/CategorySeeder.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ public function run(): void
|
|||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
|
CategorySeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FileText,
|
|
||||||
HandCoins,
|
HandCoins,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
@ -36,6 +35,7 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||||
|
|
||||||
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ const analisaItem: NavMenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const masterItems: NavMenuItem[] = [
|
const masterItems: NavMenuItem[] = [
|
||||||
{ title: 'Kategori', href: '#', icon: Tags },
|
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
|
||||||
{ title: 'Produk', href: '#', icon: Package },
|
{ title: 'Produk', href: '#', icon: Package },
|
||||||
{ title: 'Bahan Baku', href: '#', icon: Boxes },
|
{ title: 'Bahan Baku', href: '#', icon: Boxes },
|
||||||
{ title: 'Supplier', href: '#', icon: Truck },
|
{ title: 'Supplier', href: '#', icon: Truck },
|
||||||
@ -64,7 +64,6 @@ const kelolaItems: NavMenuItem[] = [
|
|||||||
{ title: 'Cutting', href: '#', icon: Scissors },
|
{ title: 'Cutting', href: '#', icon: Scissors },
|
||||||
{ title: 'Restock', href: '#', icon: RefreshCw },
|
{ title: 'Restock', href: '#', icon: RefreshCw },
|
||||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||||
{ title: 'Pesanan', href: '#', icon: FileText },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const keuanganItems: NavMenuItem[] = [
|
const keuanganItems: NavMenuItem[] = [
|
||||||
|
|||||||
59
resources/js/components/confirm-dialog.tsx
Normal file
59
resources/js/components/confirm-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
resources/js/components/data-table.tsx
Normal file
371
resources/js/components/data-table.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
@ -37,7 +37,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -54,28 +54,24 @@ function DialogContent({
|
|||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
<DialogPrimitive.Close
|
||||||
<Button
|
data-slot="dialog-close"
|
||||||
variant="ghost"
|
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"
|
||||||
className="absolute top-2 right-2"
|
>
|
||||||
size="icon-sm"
|
<XIcon />
|
||||||
>
|
<span className="sr-only">Close</span>
|
||||||
<XIcon
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
@ -87,7 +83,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -127,7 +123,7 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("font-heading text-sm font-medium", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -140,10 +136,7 @@ function DialogDescription({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
"text-xs/relaxed text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
114
resources/js/components/ui/table.tsx
Normal file
114
resources/js/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
114
resources/js/pages/admin/master/category/columns.tsx
Normal file
114
resources/js/pages/admin/master/category/columns.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
206
resources/js/pages/admin/master/category/index.tsx
Normal file
206
resources/js/pages/admin/master/category/index.tsx
Normal 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(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -1,11 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::inertia('/', 'welcome')->name('home');
|
Route::inertia('/', 'welcome')->name('home');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
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';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
160
tests/Feature/Admin/Master/CategoryTest.php
Normal file
160
tests/Feature/Admin/Master/CategoryTest.php
Normal 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]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user