diff --git a/app/Livewire/Datatable/CategoriesTable.php b/app/Livewire/Datatable/CategoriesTable.php
new file mode 100644
index 0000000..37aa308
--- /dev/null
+++ b/app/Livewire/Datatable/CategoriesTable.php
@@ -0,0 +1,89 @@
+searchable(),
+
+ Column::make('Urutan', 'sort_order')
+ ->sortable()
+ ->hideIf(false),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+ $maxSortOrder = Category::max('sort_order');
+
+ if ($row->sort_order > 1) {
+ $actions .= view('components.datatables.sort-button', [
+ 'id' => $row->id,
+ 'direction' => 'up',
+ 'tooltip' => 'Naik',
+ 'color' => 'cyan',
+ 'icon' => 'arrow-up',
+ ])->render();
+
+ $actions .= view('components.datatables.sort-button', [
+ 'id' => $row->id,
+ 'direction' => 'first',
+ 'tooltip' => 'Pertama',
+ 'color' => 'emerald',
+ 'icon' => 'bars-arrow-up',
+ ])->render();
+ }
+
+ if ($row->sort_order < $maxSortOrder) {
+ $actions .= view('components.datatables.sort-button', [
+ 'id' => $row->id,
+ 'direction' => 'down',
+ 'tooltip' => 'Turun',
+ 'color' => 'orange',
+ 'icon' => 'arrow-down',
+ ])->render();
+
+ $actions .= view('components.datatables.sort-button', [
+ 'id' => $row->id,
+ 'direction' => 'last',
+ 'tooltip' => 'Terakhir',
+ 'color' => 'rose',
+ 'icon' => 'bars-arrow-down',
+ ])->render();
+ }
+
+ $actions .= view('components.datatables.edit-modal', [
+ 'id' => $row->id,
+ 'method' => 'update',
+ 'modalTitle' => 'Ubah Kategori',
+ ])->render();
+
+ $actions .= view('components.datatables.delete', [
+ 'id' => $row->id,
+ 'deleteRoute' => route('studio.catalog.category.delete', $row->id),
+ ])->render();
+
+ return $actions;
+ })
+ ->html(),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Category::select('id', 'name', 'sort_order')->orderBy('sort_order');
+ }
+}
diff --git a/app/Livewire/Forms/CategoryForm.php b/app/Livewire/Forms/CategoryForm.php
new file mode 100644
index 0000000..ab72bc2
--- /dev/null
+++ b/app/Livewire/Forms/CategoryForm.php
@@ -0,0 +1,110 @@
+ ['required', 'string', 'max:20', Rule::unique('categories', 'name')->whereNull('deleted_at')->ignore($this->category)],
+ 'description' => ['nullable', 'string'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'name' => 'nama',
+ 'description' => 'syarat dan ketentuan',
+ ];
+ }
+
+ public function setCategory(Category $category)
+ {
+ $this->category = $category;
+
+ $this->name = $category->name;
+ $this->description = $category->description;
+ }
+
+ public function store()
+ {
+ $this->validate();
+
+ Category::create([
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'sort_order' => Category::count() + 1,
+ ]);
+ }
+
+ public function update()
+ {
+ $this->validate();
+
+ $this->category->update([
+ 'name' => $this->name,
+ 'description' => $this->description,
+ ]);
+ }
+
+ public function delete()
+ {
+ $this->category->delete();
+
+ Category::where('sort_order', '>', $this->category->sort_order)
+ ->orderBy('sort_order')
+ ->get()
+ ->each(function ($cat) {
+ $cat->update(['sort_order' => $cat->sort_order - 1]);
+ });
+ }
+
+ public function sortOrder(string $direction)
+ {
+ if (in_array($direction, ['up', 'down'])) {
+ $swap = null;
+
+ if ($direction === 'up') {
+ $swap = Category::where('sort_order', '<', $this->category->sort_order)
+ ->orderByDesc('sort_order')
+ ->first();
+ } else {
+ $swap = Category::where('sort_order', '>', $this->category->sort_order)
+ ->orderBy('sort_order')
+ ->first();
+ }
+
+ if ($swap) {
+ $temp = $this->category->sort_order;
+ $this->category->update(['sort_order' => $swap->sort_order]);
+ $swap->update(['sort_order' => $temp]);
+ }
+ } elseif ($direction === 'first') {
+ $this->category->update(['sort_order' => Category::min('sort_order') - 1]);
+ $this->normalizeSortOrder();
+ } elseif ($direction === 'last') {
+ $this->category->update(['sort_order' => Category::max('sort_order') + 1]);
+ $this->normalizeSortOrder();
+ }
+ }
+
+ protected function normalizeSortOrder()
+ {
+ $categories = Category::orderBy('sort_order')->get();
+ foreach ($categories as $index => $category) {
+ $category->update(['sort_order' => $index + 1]);
+ }
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Category.php b/app/Livewire/Studio/Catalog/Category.php
new file mode 100644
index 0000000..a982fed
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Category.php
@@ -0,0 +1,105 @@
+resetValidation();
+ $this->resetErrorBag();
+
+ $this->method = $method;
+ $this->modalTitle = $modalTitle;
+
+ if ($id) {
+ $this->form->setCategory(CategoryModel::findOrFail($id));
+ }
+ }
+
+ public function create()
+ {
+ $this->form->store();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Kategori berhasil ditambahkan.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ Flux::modals()->close();
+ }
+
+ public function update()
+ {
+ $this->form->update();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Kategori berhasil diperbarui.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ Flux::modals()->close();
+ }
+
+ public function delete(CategoryModel $category)
+ {
+ $this->form->category = $category;
+
+ $this->form->delete();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Kategori berhasil dihapus.',
+ variant: 'success',
+ );
+
+ Flux::modals()->close();
+ }
+
+ #[On('fn:sortOrder')]
+ public function sortOrder(CategoryModel $category, string $direction)
+ {
+ $this->form->category = $category;
+
+ $this->form->sortOrder($direction);
+
+ $this->dispatch('refreshDatatable');
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.categories', [
+ 'pageTitle' => 'Kategori',
+ ]);
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..81cf951
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,30 @@
+ 'int',
+ ];
+ }
+
+ public function getSlugOptions(): SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('name')
+ ->saveSlugsTo('slug');
+ }
+}
diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php
new file mode 100644
index 0000000..7b4f951
--- /dev/null
+++ b/database/factories/CategoryFactory.php
@@ -0,0 +1,23 @@
+ $this->faker->unique()->name(),
+ 'slug' => $this->faker->slug(),
+ 'description' => $this->faker->optional()->text(),
+ 'sort_order' => function () {
+ $max = Category::max('sort_order') ?? 0;
+
+ return $max + 1;
+ },
+ ];
+ }
+}
diff --git a/database/migrations/2025_09_29_062630_create_categories_table.php b/database/migrations/2025_09_29_062630_create_categories_table.php
new file mode 100644
index 0000000..843367b
--- /dev/null
+++ b/database/migrations/2025_09_29_062630_create_categories_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->string('name', 20);
+ $table->string('slug', 30);
+ $table->text('description')->nullable();
+ $table->unsignedInteger('sort_order');
+ $table->timestamps();
+ $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..622064b
--- /dev/null
+++ b/database/seeders/CategorySeeder.php
@@ -0,0 +1,33 @@
+ 'Floral', 'description' => 'Perfume with floral scents', 'sort_order' => 1],
+ ['name' => 'Citrus', 'description' => 'Fresh citrus fragrances', 'sort_order' => 2],
+ ['name' => 'Woody', 'description' => 'Warm woody notes', 'sort_order' => 3],
+ ['name' => 'Oriental', 'description' => 'Spicy and exotic oriental scents', 'sort_order' => 4],
+ ['name' => 'Fruity', 'description' => 'Sweet and fresh fruity fragrances', 'sort_order' => 5],
+ ['name' => 'Aquatic', 'description' => 'Light, oceanic, and fresh scents', 'sort_order' => 6],
+ ['name' => 'Gourmand', 'description' => 'Edible, sweet and dessert-like scents', 'sort_order' => 7],
+ ['name' => 'Green', 'description' => 'Fresh, leafy, and herbal notes', 'sort_order' => 8],
+ ['name' => 'Chypre', 'description' => 'Classic blend of citrus, oakmoss, and labdanum', 'sort_order' => 9],
+ ['name' => 'Musk', 'description' => 'Warm, sensual musky fragrances', 'sort_order' => 10],
+ ];
+
+ foreach ($categories as $category) {
+ Category::create([
+ 'name' => $category['name'],
+ 'description' => $category['description'],
+ 'sort_order' => $category['sort_order'],
+ ]);
+ }
+ }
+}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 9365482..9f7a1ec 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -12,6 +12,7 @@ public function run(): void
UserSeeder::class,
MembershipSeeder::class,
VoucherSeeder::class,
+ CategorySeeder::class,
]);
}
}
diff --git a/resources/views/components/datatables/sort-button.blade.php b/resources/views/components/datatables/sort-button.blade.php
new file mode 100644
index 0000000..bcc2d9e
--- /dev/null
+++ b/resources/views/components/datatables/sort-button.blade.php
@@ -0,0 +1,5 @@
+