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 + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + + + + + 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'); + }); });