diff --git a/app/Livewire/Datatable/Studio/Manage/PurchasesTable.php b/app/Livewire/Datatable/Studio/Manage/PurchasesTable.php
new file mode 100644
index 0000000..9b7c1e4
--- /dev/null
+++ b/app/Livewire/Datatable/Studio/Manage/PurchasesTable.php
@@ -0,0 +1,77 @@
+searchable(),
+
+ Column::make('Nomor Faktur', 'invoice_number')->searchable(),
+
+ Column::make('Tanggal Belanja', 'purchase_date')
+ ->format(fn ($value) => formatDate($value))
+ ->searchable()
+ ->sortable(),
+
+ Column::make('Total', 'total')
+ ->format(fn ($value) => currency($value, 'Rp'))
+ ->searchable()
+ ->sortable(),
+
+ ArrayColumn::make('Item')
+ ->data(
+ fn ($value, $row) => $row->items->map(fn ($item) => [
+ 'name' => $item->purchasable?->name,
+ 'quantity' => currency($item->quantity, ''),
+ 'unit_price' => currency($item->unit_price, 'Rp'),
+ 'total_price' => currency($item->total_price, 'Rp'),
+ ])->toArray()
+ )
+ ->outputFormat(
+ fn ($index, $value) => "
+
+
{$value['name']}
+
{$value['quantity']} x {$value['unit_price']}
+
{$value['total_price']}
+
"
+ )
+ ->flexCol(['class' => 'flex-col gap-3']),
+
+ Column::make('Aksi')
+ ->label(function ($row) {
+ $actions = '';
+
+ if (auth()->user()->can('delete purchase')) {
+ $actions .= view('components.datatables.delete', [
+ 'id' => $row->hash,
+ 'deleteRoute' => route('studio.manage.purchase.delete', $row->hash),
+ ])->render();
+ }
+
+ return $actions;
+ })
+ ->html(),
+ ];
+ }
+
+ public function builder(): Builder
+ {
+ return Purchase::select('purchases.id', 'invoice_number', 'purchase_date', 'total')->with('outlet');
+ }
+}
diff --git a/app/Livewire/Forms/Studio/Manage/PurchaseForm.php b/app/Livewire/Forms/Studio/Manage/PurchaseForm.php
new file mode 100644
index 0000000..3a46ebb
--- /dev/null
+++ b/app/Livewire/Forms/Studio/Manage/PurchaseForm.php
@@ -0,0 +1,126 @@
+ ['nullable', 'string', 'max:50'],
+ 'note' => ['nullable', 'string', 'max:100'],
+ 'purchase_date' => ['required', 'date'],
+ 'outlet_id' => ['required', Rule::exists('outlets', 'id')],
+ 'total' => ['required', new UnsignedInteger],
+ 'image' => ['nullable', 'array', 'max:1'],
+ ];
+ }
+
+ public function validationAttributes(): array
+ {
+ return [
+ 'invoice_number' => 'nomor faktur',
+ 'note' => 'catatan',
+ 'purchase_date' => 'tanggal belanja',
+ 'outlet_id' => 'outlet',
+ 'image' => 'gambar',
+ ];
+ }
+
+ public function setPurchase(Purchase $purchase)
+ {
+ $this->purchase = $purchase;
+
+ $this->invoice_number = $purchase->invoice_number;
+ $this->note = $purchase->note;
+ $this->purchase_date = $purchase->purchase_date;
+ $this->outlet_id = $purchase->outlet_id;
+ $this->total = $purchase->total;
+
+ $this->image = $this->mapMediaCollection($purchase->getMedia('image'));
+ }
+
+ public function store()
+ {
+ $this->validate();
+
+ DB::transaction(function () {
+ $purchase = Purchase::create($this->prepareSavedData());
+
+ $purchase->load('outlet');
+
+ $this->uploadMedia($this->image, $purchase, 'image');
+
+ foreach ($this->cartItems as $item) {
+ $item->update(['purchase_id' => $purchase->id]);
+
+ $this->increaseOutletStock($purchase->outlet, $item);
+ }
+ });
+ }
+
+ public function update()
+ {
+ $this->validate();
+
+ DB::transaction(function () {
+ $this->purchase->update($this->prepareSavedData());
+
+ $this->syncMedia($this->image, $this->purchase, 'image');
+ $this->uploadMedia($this->image, $this->purchase, 'image');
+ });
+ }
+
+ private function prepareSavedData(): array
+ {
+ return [
+ 'invoice_number' => $this->invoice_number,
+ 'note' => $this->note,
+ 'outlet_id' => $this->outlet_id,
+ 'purchase_date' => $this->purchase_date,
+ 'total' => replaceCurrency($this->total),
+ ];
+ }
+}
diff --git a/app/Livewire/Studio/Manage/Purchase/Create.php b/app/Livewire/Studio/Manage/Purchase/Create.php
new file mode 100644
index 0000000..578da7a
--- /dev/null
+++ b/app/Livewire/Studio/Manage/Purchase/Create.php
@@ -0,0 +1,76 @@
+outlets = Outlet::pluck('name', 'id')->toArray();
+
+ $this->perfumes = Perfume::pluck('name', 'id')->toArray();
+
+ $this->products = Product::pluck('name', 'id')->toArray();
+
+ $this->bottles = Bottle::pluck('name', 'id')->toArray();
+
+ $this->form->cartItems = PurchaseItem::whereNull('purchase_id')->get();
+
+ $this->form->total = currency($this->form->cartItems->sum('total_price'), '');
+ }
+
+ public function save()
+ {
+ if ($this->form->cartItems->isEmpty()) {
+ $this->toast('Keranjang tidak boleh kosong.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $this->canOrAbort('create purchase');
+
+ $this->form->store();
+
+ $this->dispatch('refreshDatatable');
+
+ $this->toast('Belanja berhasil ditambahkan.');
+
+ $this->redirectRoute('studio.manage.purchase.index', navigate: true);
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.manage.purchase.form', [
+ 'pageTitle' => 'Tambah Belanja',
+ ]);
+ }
+}
diff --git a/app/Livewire/Studio/Manage/Purchase/Index.php b/app/Livewire/Studio/Manage/Purchase/Index.php
new file mode 100644
index 0000000..d916470
--- /dev/null
+++ b/app/Livewire/Studio/Manage/Purchase/Index.php
@@ -0,0 +1,35 @@
+delete();
+
+ $this->dispatch('refreshDatatable');
+
+ $this->toast('Belanja berhasil dihapus.');
+
+ Flux::modals()->close();
+ }
+
+ public function render()
+ {
+ return view('livewire.studio.manage.purchase.index', [
+ 'pageTitle' => 'Belanja',
+ ]);
+ }
+}
diff --git a/app/Models/Bottle.php b/app/Models/Bottle.php
index 982c3e0..c611f09 100644
--- a/app/Models/Bottle.php
+++ b/app/Models/Bottle.php
@@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@@ -40,4 +41,9 @@ public function outlets(): BelongsToMany
{
return $this->belongsToMany(Outlet::class)->withPivot('stock');
}
+
+ public function purchaseItems(): MorphMany
+ {
+ return $this->morphMany(PurchaseItem::class, 'purchasable');
+ }
}
diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php
index 0f07fe4..153a689 100644
--- a/app/Models/Outlet.php
+++ b/app/Models/Outlet.php
@@ -76,4 +76,9 @@ public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
+
+ public function purchases(): HasMany
+ {
+ return $this->hasMany(Purchase::class);
+ }
}
diff --git a/app/Models/Perfume.php b/app/Models/Perfume.php
index 444ebcc..bea35e1 100644
--- a/app/Models/Perfume.php
+++ b/app/Models/Perfume.php
@@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@@ -50,4 +51,9 @@ public function outlets(): BelongsToMany
{
return $this->belongsToMany(Outlet::class)->withPivot('stock');
}
+
+ public function purchaseItems(): MorphMany
+ {
+ return $this->morphMany(PurchaseItem::class, 'purchasable');
+ }
}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index 9b6f391..deb3417 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
@@ -37,4 +38,9 @@ public function outlets(): BelongsToMany
{
return $this->belongsToMany(Outlet::class)->withPivot('stock');
}
+
+ public function purchaseItems(): MorphMany
+ {
+ return $this->morphMany(PurchaseItem::class, 'purchasable');
+ }
}
diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php
new file mode 100644
index 0000000..33d4e51
--- /dev/null
+++ b/app/Models/Purchase.php
@@ -0,0 +1,36 @@
+ 'int',
+ ];
+ }
+
+ public function outlet(): BelongsTo
+ {
+ return $this->belongsTo(Outlet::class);
+ }
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(PurchaseItem::class);
+ }
+}
diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php
new file mode 100644
index 0000000..5e40459
--- /dev/null
+++ b/app/Models/PurchaseItem.php
@@ -0,0 +1,35 @@
+ 'int',
+ 'unit_price' => 'int',
+ 'total_price' => 'int',
+ ];
+ }
+
+ public function purchase(): BelongsTo
+ {
+ return $this->belongsTo(Purchase::class);
+ }
+
+ public function purchasable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php
index ed19a5c..c04c620 100644
--- a/app/Providers/ViewServiceProvider.php
+++ b/app/Providers/ViewServiceProvider.php
@@ -130,6 +130,13 @@ public function boot(): void
'match' => 'studio.manage.article.*',
'can' => 'view article',
],
+ [
+ 'label' => 'Belanja',
+ 'icon' => 'shopping-bag',
+ 'route' => 'studio.manage.purchase.index',
+ 'match' => 'studio.manage.purchase.*',
+ 'can' => 'view purchase',
+ ],
],
],
];
diff --git a/app/Traits/Purchase/WithAddItem.php b/app/Traits/Purchase/WithAddItem.php
new file mode 100644
index 0000000..5664b73
--- /dev/null
+++ b/app/Traits/Purchase/WithAddItem.php
@@ -0,0 +1,100 @@
+form->perfume_id;
+ $quantity = replaceCurrency($this->form->quantity_perfume);
+ break;
+
+ case 'produk':
+ $model = Product::class;
+ $id = $this->form->product_id;
+ $quantity = replaceCurrency($this->form->quantity_product);
+ break;
+
+ case 'botol':
+ $model = Bottle::class;
+ $id = $this->form->bottle_id;
+ $quantity = replaceCurrency($this->form->quantity_bottle);
+ break;
+
+ default:
+ $this->toast('Tipe item tidak dikenal.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $itemModel = $model::find($id);
+
+ if (! $itemModel) {
+ $this->toast(Str::ucfirst($type).' tidak ditemukan, silakan periksa kembali.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $costPrice = (int) $itemModel->cost_price;
+
+ if ($quantity <= 0) {
+ $this->toast('Jumlah '.$type.' tidak valid, silakan periksa kembali.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ if ($costPrice <= 0) {
+ $this->toast('Harga '.$type.' tidak valid, silakan periksa kembali.', 'Gagal', 'danger');
+
+ return;
+ }
+
+ $existingItem = $this->form->cartItems
+ ->where('purchasable_type', $model)
+ ->where('purchasable_id', $itemModel->id)
+ ->first();
+
+ if ($existingItem) {
+ $existingItem->quantity += $quantity;
+ $existingItem->total_price = $existingItem->unit_price * $existingItem->quantity;
+ $existingItem->save();
+
+ $existingItem->refresh();
+
+ $this->form->cartItems = $this->form->cartItems->map(fn ($i) => $i->id === $existingItem->id ? $existingItem : $i);
+
+ $this->toast('Jumlah '.$type.' diperbarui di keranjang.', 'Berhasil');
+ } else {
+ $item = PurchaseItem::create([
+ 'purchasable_id' => $itemModel->id,
+ 'purchasable_type' => $model,
+ 'quantity' => $quantity,
+ 'unit_price' => $costPrice,
+ 'total_price' => $costPrice * $quantity,
+ ]);
+
+ $this->form->cartItems->push($item);
+
+ $this->toast(Str::ucfirst($type).' ditambahkan ke keranjang.', 'Berhasil');
+ }
+
+ $this->form->total = currency($this->form->cartItems->sum('total_price'), '');
+
+ $this->reset('form.perfume_id');
+ $this->reset('form.product_id');
+ $this->reset('form.bottle_id');
+ $this->reset('form.quantity_perfume');
+ $this->reset('form.quantity_product');
+ $this->reset('form.quantity_bottle');
+ }
+}
diff --git a/app/Traits/Purchase/WithDeleteItem.php b/app/Traits/Purchase/WithDeleteItem.php
new file mode 100644
index 0000000..93d8214
--- /dev/null
+++ b/app/Traits/Purchase/WithDeleteItem.php
@@ -0,0 +1,19 @@
+form->cartItems = $this->form->cartItems->reject(fn ($i) => $i->id === $item->id);
+
+ $item->delete();
+
+ $this->form->total = currency($this->form->cartItems->sum('total_price'), '');
+
+ $this->toast('Item berhasil dihapus.');
+ }
+}
diff --git a/app/Traits/Purchase/WithUpdateItem.php b/app/Traits/Purchase/WithUpdateItem.php
new file mode 100644
index 0000000..60e32f1
--- /dev/null
+++ b/app/Traits/Purchase/WithUpdateItem.php
@@ -0,0 +1,47 @@
+modalTitle = $item->purchasable->name;
+
+ $this->form->item_id = $item->id;
+
+ $this->form->quantity_edit = currency($item->quantity, '');
+ }
+
+ public function closeModal()
+ {
+ $this->reset('form.item_id');
+ }
+
+ public function updateItem()
+ {
+ $item = PurchaseItem::find($this->form->item_id);
+
+ $item->update([
+ 'quantity' => replaceCurrency($this->form->quantity_edit),
+ 'total_price' => (int) $item->purchasable->cost_price * (int) replaceCurrency($this->form->quantity_edit),
+ ]);
+
+ $item->refresh();
+
+ $this->form->cartItems = $this->form->cartItems->map(fn ($i) => $i->id === $item->id ? $item : $i);
+
+ $this->form->total = currency($this->form->cartItems->sum('total_price'), '');
+
+ $this->toast('Item berhasil diperbarui.');
+
+ Flux::modals('form-modal')->close();
+ }
+}
diff --git a/app/Traits/Purchase/WithUpdateStock.php b/app/Traits/Purchase/WithUpdateStock.php
new file mode 100644
index 0000000..e357e86
--- /dev/null
+++ b/app/Traits/Purchase/WithUpdateStock.php
@@ -0,0 +1,44 @@
+quantity;
+
+ switch ($item->purchasable_type) {
+ case Perfume::class:
+ $relation = 'perfumes';
+ break;
+ case Product::class:
+ $relation = 'products';
+ break;
+ case Bottle::class:
+ $relation = 'bottles';
+ break;
+ default:
+ return;
+ }
+
+ $existing = $outlet->{$relation}()
+ ->where("{$relation}.id", $item->purchasable_id)
+ ->first();
+
+ if ($existing) {
+ $currentStock = $existing->pivot->stock ?? 0;
+ $outlet->{$relation}()->updateExistingPivot($item->purchasable_id, [
+ 'stock' => $currentStock + $quantity,
+ ]);
+ } else {
+ $outlet->{$relation}()->attach($item->purchasable_id, [
+ 'stock' => $quantity,
+ ]);
+ }
+ }
+}
diff --git a/database/migrations/2025_10_03_080030_create_purchases_table.php b/database/migrations/2025_10_03_080030_create_purchases_table.php
new file mode 100644
index 0000000..82e58ca
--- /dev/null
+++ b/database/migrations/2025_10_03_080030_create_purchases_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
+ $table->string('invoice_number', 50)->nullable();
+ $table->date('purchase_date');
+ $table->unsignedInteger('total');
+ $table->string('note', 100)->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('purchases');
+ }
+};
diff --git a/database/migrations/2025_10_03_080031_create_purchase_items_table.php b/database/migrations/2025_10_03_080031_create_purchase_items_table.php
new file mode 100644
index 0000000..1732367
--- /dev/null
+++ b/database/migrations/2025_10_03_080031_create_purchase_items_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('purchase_id')->nullable()->constrained()->cascadeOnDelete();
+ $table->morphs('purchasable');
+ $table->unsignedInteger('quantity');
+ $table->unsignedInteger('unit_price');
+ $table->unsignedInteger('total_price');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('purchase_items');
+ }
+};
diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php
index c77678b..6cda09d 100644
--- a/database/seeders/RolePermissionSeeder.php
+++ b/database/seeders/RolePermissionSeeder.php
@@ -88,6 +88,10 @@ public function run(): void
'create article',
'update article',
'delete article',
+
+ 'view purchase',
+ 'create purchase',
+ 'delete purchase',
];
foreach ($permissions as $permission) {
diff --git a/resources/views/livewire/studio/manage/purchase/form.blade.php b/resources/views/livewire/studio/manage/purchase/form.blade.php
new file mode 100644
index 0000000..b6d0528
--- /dev/null
+++ b/resources/views/livewire/studio/manage/purchase/form.blade.php
@@ -0,0 +1,209 @@
+