feat: implement Category management with CRUD operations, validation, and UI integration
This commit is contained in:
parent
0aabc1ba71
commit
13667406d9
65
app/Http/Controllers/Admin/Master/CategoryController.php
Normal file
65
app/Http/Controllers/Admin/Master/CategoryController.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Master\CategoryRequest;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/master/category/index', [
|
||||
'categories' => Category::latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
Category::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil disimpan');
|
||||
}
|
||||
|
||||
public function update(CategoryRequest $request, Category $category): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function destroy(Category $category): RedirectResponse
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data berhasil dihapus');
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request): RedirectResponse
|
||||
{
|
||||
$ids = $request->input('ids');
|
||||
|
||||
Category::whereIn('id', $ids)->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
|
||||
}
|
||||
|
||||
public function toggleStatus(Category $category): RedirectResponse
|
||||
{
|
||||
$category->update([
|
||||
'is_active' => ! $category->is_active,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Status berhasil diperbarui');
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal file
29
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Models/Category.php
Normal file
30
app/Models/Category.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Sluggable\HasSlug;
|
||||
use Spatie\Sluggable\SlugOptions;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory, HasSlug, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSlugOptions(): SlugOptions
|
||||
{
|
||||
return SlugOptions::create()
|
||||
->generateSlugsFrom('title')
|
||||
->saveSlugsTo('slug');
|
||||
}
|
||||
}
|
||||
27
database/factories/CategoryFactory.php
Normal file
27
database/factories/CategoryFactory.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Category>
|
||||
*/
|
||||
class CategoryFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$title = str()->limit($this->faker->sentence, 40);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'slug' => str()->slug($title),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title', 50);
|
||||
$table->string('slug', 50);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
26
database/seeders/CategorySeeder.php
Normal file
26
database/seeders/CategorySeeder.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
['title' => 'Set Celana', 'slug' => 'set-celana', 'is_active' => true],
|
||||
['title' => 'Gamis', 'slug' => 'gamis', 'is_active' => true],
|
||||
['title' => 'Mukena', 'slug' => 'mukena', 'is_active' => true],
|
||||
['title' => 'Dress', 'slug' => 'dress', 'is_active' => true],
|
||||
];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
Category::create($category);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ public function run(): void
|
||||
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
CategorySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import category from '@/routes/category';
|
||||
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
@ -24,7 +26,7 @@ const mainNavItems: NavItem[] = [
|
||||
const masterNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Kategori',
|
||||
href: dashboard(),
|
||||
href: category.index().url,
|
||||
icon: List,
|
||||
},
|
||||
];
|
||||
@ -45,8 +47,8 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} label='Platform' />
|
||||
<NavMain items={masterNavItems} label='Master Data' />
|
||||
<NavMain items={mainNavItems} />
|
||||
<NavMain items={masterNavItems} label='Master' />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
313
resources/js/pages/admin/master/category/index.tsx
Normal file
313
resources/js/pages/admin/master/category/index.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import { Head, useForm, router, usePage } from '@inertiajs/react';
|
||||
import type { Category } from '@/types';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Pencil } from 'lucide-react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldGroup } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import categoryRoutes from '@/routes/category';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
export default function CategoryIndex({ categories }: { categories: Category[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||||
|
||||
const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm({
|
||||
title: '',
|
||||
});
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const onEdit = (category: Category) => {
|
||||
setIsEditing(true);
|
||||
setSelectedCategory(category);
|
||||
setData({
|
||||
title: category.title,
|
||||
});
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const onDelete = (category: Category) => {
|
||||
setCategoryToDelete(category);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (categoryToDelete) {
|
||||
router.delete(categoryRoutes.destroy(categoryToDelete.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setCategoryToDelete(null);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
router.post(categoryRoutes.bulkDestroy().url, {
|
||||
ids: rowsToDelete.map((row: any) => row.id),
|
||||
_method: 'DELETE'
|
||||
}, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setRowsToDelete([]);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onToggleStatus = (id: number) => {
|
||||
router.patch(categoryRoutes.toggleStatus(id).url, {}, {
|
||||
onSuccess: (response: any) => toast.success(response.props.flash.success),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
setIsEditing(false);
|
||||
setSelectedCategory(null);
|
||||
reset();
|
||||
clearErrors();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isEditing && selectedCategory) {
|
||||
patch(categoryRoutes.update(selectedCategory.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
post(categoryRoutes.store().url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Category>[] = [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableColumnHeader column={column} title="Judul" />
|
||||
)
|
||||
},
|
||||
meta: { title: "Judul" },
|
||||
},
|
||||
{
|
||||
accessorKey: "is_active",
|
||||
header: "Status",
|
||||
meta: { title: "Status" },
|
||||
cell: ({ row }) => {
|
||||
const category = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={category.is_active}
|
||||
onCheckedChange={() => onToggleStatus(category.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const category = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(category)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ubah</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(category)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hapus</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Aksi" },
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Kategori" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kategori</h1>
|
||||
</div>
|
||||
<Button onClick={() => setIsOpen(true)}>
|
||||
Tambah
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Ubah Kategori' : 'Tambah Kategori'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<Label htmlFor="title">Judul</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={data.title}
|
||||
onChange={e => setData('title', e.target.value)}
|
||||
autoComplete='off'
|
||||
placeholder='Contoh: Gamis'
|
||||
maxLength={50}
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-500">{errors.title}</p>}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={closeModal}>Batal</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||
<CardContent className="p-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={categories}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'is_active',
|
||||
title: 'Status',
|
||||
options: [
|
||||
{ label: 'Aktif', value: 'true' },
|
||||
{ label: 'Tidak Aktif', value: 'false' },
|
||||
]
|
||||
}
|
||||
]}
|
||||
bulkActions={[
|
||||
{
|
||||
label: 'Hapus Terpilih',
|
||||
onClick: (rows) => {
|
||||
setRowsToDelete(rows);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
},
|
||||
icon: Trash2,
|
||||
variant: 'destructive'
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus kategori?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. Kategori <strong>{categoryToDelete?.title}</strong> akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus {rowsToDelete.length} kategori?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CategoryIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Master',
|
||||
},
|
||||
],
|
||||
};
|
||||
9
resources/js/types/category.ts
Normal file
9
resources/js/types/category.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export type * from './auth';
|
||||
export type * from './navigation';
|
||||
export type * from './ui';
|
||||
export type * from './category';
|
||||
|
||||
15
routes/master.php
Normal file
15
routes/master.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::prefix('admin/master')->group(function () {
|
||||
Route::get('categories', [CategoryController::class, 'index'])->name('category.index');
|
||||
Route::post('category/store', [CategoryController::class, 'store'])->name('category.store');
|
||||
Route::patch('category/update/{category}', [CategoryController::class, 'update'])->name('category.update');
|
||||
Route::delete('category/destroy/{category}', [CategoryController::class, 'destroy'])->name('category.destroy');
|
||||
Route::delete('category/bulk-destroy', [CategoryController::class, 'bulkDestroy'])->name('category.bulkDestroy');
|
||||
Route::patch('category/toggle-status/{category}', [CategoryController::class, 'toggleStatus'])->name('category.toggleStatus');
|
||||
});
|
||||
});
|
||||
@ -12,3 +12,4 @@
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/master.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user