diff --git a/app/Livewire/Datatable/ProductsTable.php b/app/Livewire/Datatable/ProductsTable.php
new file mode 100644
index 0000000..4b84587
--- /dev/null
+++ b/app/Livewire/Datatable/ProductsTable.php
@@ -0,0 +1,78 @@
+label(function ($row) {
+ return <<
+
{$row->name}
+ {$row->sku}
+
+ HTML;
+ })
+ ->searchable(function ($query, $searchTerm) {
+ $query->where('name', 'like', "%{$searchTerm}%")
+ ->orWhere('sku', 'like', "%{$searchTerm}%");
+ })
+ ->html(),
+
+ Column::make('Harga Beli', 'cost_price')
+ ->label(fn ($row, $column) => currency($row->cost_price, 'Rp'))
+ ->searchable()
+ ->sortable(),
+
+ Column::make('Harga Jual', 'sale_price')
+ ->label(fn ($row, $column) => currency($row->sale_price, 'Rp'))
+ ->searchable()
+ ->sortable(),
+
+ ArrayColumn::make('Outlet')
+ ->data(fn ($value, $row) => $row->outlets->pluck('name')->toArray())
+ ->outputFormat(fn ($index, $value) => Blade::render(''.$value.''))
+ ->flexRow(['class' => 'gap-2 flex-wrap']),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+
+ $actions .= view('components.datatables.edit', [
+ 'id' => $row->id,
+ 'editRoute' => route('studio.catalog.product.edit', $row->id),
+ ])->render();
+
+ $actions .= view('components.datatables.delete', [
+ 'id' => $row->id,
+ 'deleteRoute' => route('studio.catalog.product.delete', $row->id),
+ ])->render();
+
+ return $actions;
+ })
+ ->html(),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Product::select('products.id', 'sku', 'products.name', 'cost_price', 'sale_price')->with('outlets');
+ }
+}
diff --git a/app/Livewire/Forms/ProductForm.php b/app/Livewire/Forms/ProductForm.php
new file mode 100644
index 0000000..a7f6f91
--- /dev/null
+++ b/app/Livewire/Forms/ProductForm.php
@@ -0,0 +1,121 @@
+ ['required', 'string', 'max:50'],
+ 'sku' => [
+ 'required',
+ 'string',
+ 'max:20',
+ Rule::unique('products', 'sku')->ignore($this->product),
+ ],
+ 'cost_price' => ['required', 'numeric', new UnsignedInteger],
+ 'sale_price' => ['required', 'numeric', new UnsignedInteger],
+ 'description' => ['nullable', 'string'],
+ 'outlet_ids' => ['required', 'array', 'min:1'],
+ 'outlet_ids.*' => Rule::exists('outlets', 'id'),
+ 'image' => ['nullable', 'array', 'max:1'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'name' => 'nama',
+ 'cost_price' => 'harga beli',
+ 'sale_price' => 'harga jual',
+ 'description' => 'deskripsi',
+ 'outlet_ids' => 'outlet',
+ 'image' => 'gambar',
+ ];
+ }
+
+ public function setProduct(Product $product)
+ {
+ $product->load('outlets');
+
+ $this->product = $product;
+
+ $this->name = $product->name;
+ $this->sku = $product->sku;
+ $this->cost_price = $product->cost_price;
+ $this->sale_price = $product->sale_price;
+ $this->description = $product->description;
+ $this->outlet_ids = $this->product->outlets->pluck('id')->toArray();
+ $this->image = $this->mapMediaCollection($product->getMedia('image'));
+ }
+
+ public function store()
+ {
+ $this->validate();
+
+ $data = $this->prepareSavedData();
+
+ DB::transaction(function () use ($data) {
+ $product = Product::create($data);
+
+ $product->outlets()->attach($this->outlet_ids);
+
+ $this->uploadMedia($this->image, $product, 'image');
+ });
+ }
+
+ public function update()
+ {
+ $this->validate();
+
+ $data = $this->prepareSavedData();
+
+ DB::transaction(function () use ($data) {
+ $this->product->update($data);
+
+ $this->product->outlets()->sync($this->outlet_ids);
+
+ $this->syncMedia($data['image'], $this->product, 'image');
+ $this->uploadMedia($data['image'], $this->product, 'image');
+ });
+ }
+
+ private function prepareSavedData()
+ {
+ return [
+ 'name' => $this->name,
+ 'sku' => $this->sku,
+ 'cost_price' => $this->cost_price,
+ 'sale_price' => $this->sale_price,
+ 'description' => $this->description,
+ 'outlet_ids' => $this->outlet_ids,
+ 'image' => $this->image,
+ ];
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Product/Create.php b/app/Livewire/Studio/Catalog/Product/Create.php
new file mode 100644
index 0000000..522f95a
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Product/Create.php
@@ -0,0 +1,49 @@
+outlets = Outlet::pluck('name', 'id')->toArray();
+ }
+
+ public function save()
+ {
+ $this->form->store();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Produk berhasil ditambahkan.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.catalog.product.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.product.form', [
+ 'pageTitle' => 'Tambah Produk',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Product/Edit.php b/app/Livewire/Studio/Catalog/Product/Edit.php
new file mode 100644
index 0000000..bc4f0b9
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Product/Edit.php
@@ -0,0 +1,52 @@
+form->setProduct($product);
+
+ $this->outlets = Outlet::pluck('name', 'id')->toArray();
+ }
+
+ public function save()
+ {
+ $this->form->update();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Produk berhasil diperbarui.',
+ variant: 'success',
+ duration: 3000
+ );
+
+ $this->redirectRoute('studio.catalog.product.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.product.form', [
+ 'pageTitle' => 'Ubah Produk',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Catalog/Product/Index.php b/app/Livewire/Studio/Catalog/Product/Index.php
new file mode 100644
index 0000000..1fdb424
--- /dev/null
+++ b/app/Livewire/Studio/Catalog/Product/Index.php
@@ -0,0 +1,37 @@
+delete();
+
+ $this->dispatch('refreshDatatable');
+
+ Flux::toast(
+ heading: 'Berhasil',
+ text: 'Produk berhasil dihapus.',
+ variant: 'success',
+ );
+
+ Flux::modals()->close();
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.catalog.product.index', [
+ 'pageTitle' => 'Produk',
+ ]);
+ }
+}
diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php
index e529a8f..9f538d3 100644
--- a/app/Models/Outlet.php
+++ b/app/Models/Outlet.php
@@ -57,4 +57,9 @@ public function perfumes(): BelongsToMany
{
return $this->belongsToMany(Perfume::class);
}
+
+ public function products(): BelongsToMany
+ {
+ return $this->belongsToMany(Product::class);
+ }
}
diff --git a/app/Models/OutletProduct.php b/app/Models/OutletProduct.php
new file mode 100644
index 0000000..2339ee6
--- /dev/null
+++ b/app/Models/OutletProduct.php
@@ -0,0 +1,12 @@
+ 'int',
+ 'sale_price' => 'int',
+ ];
+ }
+
+ public function getSlugOptions(): SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('name')
+ ->saveSlugsTo('slug');
+ }
+
+ public function outlets(): BelongsToMany
+ {
+ return $this->belongsToMany(Outlet::class);
+ }
+}
diff --git a/database/migrations/2025_10_02_013524_create_products_table.php b/database/migrations/2025_10_02_013524_create_products_table.php
new file mode 100644
index 0000000..af7ecb3
--- /dev/null
+++ b/database/migrations/2025_10_02_013524_create_products_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->string('name', 50);
+ $table->string('slug', 70)->unique();
+ $table->string('sku', 20)->unique();
+ $table->unsignedInteger('cost_price')->default(0);
+ $table->unsignedInteger('sale_price')->default(0);
+ $table->text('description')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('products');
+ }
+};
diff --git a/database/migrations/2025_10_02_013530_create_outlet_product_table.php b/database/migrations/2025_10_02_013530_create_outlet_product_table.php
new file mode 100644
index 0000000..384b561
--- /dev/null
+++ b/database/migrations/2025_10_02_013530_create_outlet_product_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->unsignedInteger('stock')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('outlet_product');
+ }
+};
diff --git a/resources/views/components/partials/studio/_sidebar.blade.php b/resources/views/components/partials/studio/_sidebar.blade.php
index 3fe89bc..1a637c1 100644
--- a/resources/views/components/partials/studio/_sidebar.blade.php
+++ b/resources/views/components/partials/studio/_sidebar.blade.php
@@ -52,6 +52,10 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
Parfum
+
+ Produk
+
diff --git a/resources/views/flux/icon/boxes.blade.php b/resources/views/flux/icon/boxes.blade.php
new file mode 100644
index 0000000..958f9d4
--- /dev/null
+++ b/resources/views/flux/icon/boxes.blade.php
@@ -0,0 +1,52 @@
+{{-- Credit: Lucide (https://lucide.dev) --}}
+
+@props([
+ 'variant' => 'outline',
+])
+
+@php
+if ($variant === 'solid') {
+ throw new \Exception('The "solid" variant is not supported in Lucide.');
+}
+
+$classes = Flux::classes('shrink-0')
+ ->add(match($variant) {
+ 'outline' => '[:where(&)]:size-6',
+ 'solid' => '[:where(&)]:size-6',
+ 'mini' => '[:where(&)]:size-5',
+ 'micro' => '[:where(&)]:size-4',
+ });
+
+$strokeWidth = match ($variant) {
+ 'outline' => 2,
+ 'mini' => 2.25,
+ 'micro' => 2.5,
+};
+@endphp
+
+
diff --git a/resources/views/livewire/studio/catalog/product/form.blade.php b/resources/views/livewire/studio/catalog/product/form.blade.php
new file mode 100644
index 0000000..e595de9
--- /dev/null
+++ b/resources/views/livewire/studio/catalog/product/form.blade.php
@@ -0,0 +1,107 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Kembali
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Harga Beli
+
+ Rp
+
+
+
+
+
+
+ Harga Jual
+
+ Rp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pilih Semua
+
+ Hapus Semua
+
+ @foreach ($outlets as $key => $value)
+ {{ $value }}
+
+ @endforeach
+
+
+
+
+
+
Gambar
+
+
+ @error('form.image')
+
+ @enderror
+
+
+
+
+
+
+
+
+ Simpan
+
+
+
+
+
diff --git a/resources/views/livewire/studio/catalog/product/index.blade.php b/resources/views/livewire/studio/catalog/product/index.blade.php
new file mode 100644
index 0000000..87c72ec
--- /dev/null
+++ b/resources/views/livewire/studio/catalog/product/index.blade.php
@@ -0,0 +1,19 @@
+
+
+
+ {{ $pageTitle }}
+
+
+
+ Tambah
+
+
+
+
+
+
+
+
+ @include('components.confirmation.delete')
+
diff --git a/routes/web.php b/routes/web.php
index 3bddc07..0ef5948 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -8,6 +8,9 @@
use App\Livewire\Studio\Catalog\Perfume\Create as PerfumeCreate;
use App\Livewire\Studio\Catalog\Perfume\Edit as PerfumeEdit;
use App\Livewire\Studio\Catalog\Perfume\Index as PerfumeIndex;
+use App\Livewire\Studio\Catalog\Product\Create as ProductCreate;
+use App\Livewire\Studio\Catalog\Product\Edit as ProductEdit;
+use App\Livewire\Studio\Catalog\Product\Index as ProductIndex;
use App\Livewire\Studio\Dashboard\Overview;
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
@@ -127,4 +130,13 @@
Route::get('perfumes/{perfume}/edit', PerfumeEdit::class)->name('edit');
Route::get('perfumes/{perfume}/delete', PerfumeCreate::class)->name('delete');
});
+
+ Route::prefix('catalog')
+ ->as('studio.catalog.product.')
+ ->group(function () {
+ Route::get('products', ProductIndex::class)->name('index');
+ Route::get('products/create', ProductCreate::class)->name('create');
+ Route::get('products/{product}/edit', ProductEdit::class)->name('edit');
+ Route::get('products/{product}/delete', ProductCreate::class)->name('delete');
+ });
});