diff --git a/app/Http/Controllers/Admin/Master/CategoryController.php b/app/Http/Controllers/Admin/Master/CategoryController.php new file mode 100644 index 0000000..1a57fda --- /dev/null +++ b/app/Http/Controllers/Admin/Master/CategoryController.php @@ -0,0 +1,65 @@ + 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'); + } +} diff --git a/app/Http/Requests/Admin/Master/CategoryRequest.php b/app/Http/Requests/Admin/Master/CategoryRequest.php new file mode 100644 index 0000000..22bb0bb --- /dev/null +++ b/app/Http/Requests/Admin/Master/CategoryRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..e1a0492 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,30 @@ + 'boolean', + ]; + } + + public function getSlugOptions(): SlugOptions + { + return SlugOptions::create() + ->generateSlugsFrom('title') + ->saveSlugsTo('slug'); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..2d2524b --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,27 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = str()->limit($this->faker->sentence, 40); + + return [ + 'title' => $title, + 'slug' => str()->slug($title), + ]; + } +} diff --git a/database/migrations/2026_04_12_222831_create_categories_table.php b/database/migrations/2026_04_12_222831_create_categories_table.php new file mode 100644 index 0000000..0ca872a --- /dev/null +++ b/database/migrations/2026_04_12_222831_create_categories_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..442e675 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,26 @@ + '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); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4828fe7..f198fca 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,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 0e4ccee..d55325f 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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() { - - + + ); 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..7e06788 --- /dev/null +++ b/resources/js/pages/admin/master/category/index.tsx @@ -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(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(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + 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[] = [ + { + accessorKey: "title", + header: ({ column }) => { + return ( + + ) + }, + meta: { title: "Judul" }, + }, + { + accessorKey: "is_active", + header: "Status", + meta: { title: "Status" }, + cell: ({ row }) => { + const category = row.original; + return ( + onToggleStatus(category.id)} + /> + ); + } + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const category = row.original; + return ( +
+ + + + + +

Ubah

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, + ]; + + return ( +
+ + +
+
+

Kategori

+
+ +
+ + !open && closeModal()}> + + + {isEditing ? 'Ubah Kategori' : 'Tambah Kategori'} + +
+ + + + setData('title', e.target.value)} + autoComplete='off' + placeholder='Contoh: Gamis' + maxLength={50} + /> + {errors.title &&

{errors.title}

} +
+
+ + + + +
+
+
+ + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + + + + + + + Hapus kategori? + + Tindakan ini tidak dapat dibatalkan. Kategori {categoryToDelete?.title} akan dihapus secara permanen. + + + + Batal + Hapus + + + + + + + + + + + Hapus {rowsToDelete.length} kategori? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} item yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +CategoryIndex.layout = { + breadcrumbs: [ + { + title: 'Master', + }, + ], +}; diff --git a/resources/js/types/category.ts b/resources/js/types/category.ts new file mode 100644 index 0000000..0fd1a2d --- /dev/null +++ b/resources/js/types/category.ts @@ -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; +} diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index b1f0ee6..be53eca 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -1,3 +1,4 @@ export type * from './auth'; export type * from './navigation'; export type * from './ui'; +export type * from './category'; diff --git a/routes/master.php b/routes/master.php new file mode 100644 index 0000000..df57253 --- /dev/null +++ b/routes/master.php @@ -0,0 +1,15 @@ +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'); + }); +}); diff --git a/routes/web.php b/routes/web.php index b1a6160..84bd43a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,3 +12,4 @@ }); require __DIR__.'/settings.php'; +require __DIR__.'/master.php';