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 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+
+
+ +
+ + + +
+ +
+ + Tanggal Belanja * + + + + + + Total * + + Rp + + + + + + + Outlet * + + @foreach ($outlets as $key => $value) + {{ $value }} + + @endforeach + + + +
+
+ +
+ + + @foreach ($perfumes as $key => $perfume) + {{ $perfume }} + @endforeach + + + +
+ + Tambah + +
+
+ + + + @foreach ($products as $key => $product) + {{ $product }} + @endforeach + + + +
+ + Tambah + +
+
+ + + + @foreach ($bottles as $key => $bottle) + {{ $bottle }} + @endforeach + + + +
+ + Tambah + +
+
+
+
+ +
+ +
+

Gambar

+
+ + @error('form.image') +
+ {{ $message }} +
+ @enderror +
+
+
+ +

Keranjang

+ +
+ + + Item + Harga + Aksi + + + + @forelse($form->cartItems as $item) + + + +
+
{{ $item->purchasable->name }}
+ {{ currency($item->quantity, '') }} + x + {{ currency($item->purchasable->cost_price, 'Rp') }} +
+
+
+ + {{ currency($item->total_price, 'Rp') }} + + + + + + + + + + + + +
+ @empty + + + Keranjang Kosong... + + + @endforelse +
+
+
+
+
+
+ +
+ + Simpan + +
+
+ + +
+ Ubah {{ $modalTitle }} + + + +
+ + + Perbarui + +
+
+
+
diff --git a/resources/views/livewire/studio/manage/purchase/index.blade.php b/resources/views/livewire/studio/manage/purchase/index.blade.php new file mode 100644 index 0000000..a78083a --- /dev/null +++ b/resources/views/livewire/studio/manage/purchase/index.blade.php @@ -0,0 +1,21 @@ + +
+
+ {{ $pageTitle }} +
+ @if (auth()->user()->can('create purchase')) +
+ + Tambah + +
+ @endif +
+ +
+ +
+ + @include('components.confirmation.delete') +
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index ca7bec5..633104b 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -21,6 +21,8 @@ use App\Livewire\Studio\Manage\Article\Create as ArticleCreate; use App\Livewire\Studio\Manage\Article\Edit as ArticleEdit; use App\Livewire\Studio\Manage\Article\Index as ArticleIndex; +use App\Livewire\Studio\Manage\Purchase\Create as PurchaseCreate; +use App\Livewire\Studio\Manage\Purchase\Index as PurchaseIndex; use App\Livewire\Studio\Master\Outlet\Create as OutletCreate; use App\Livewire\Studio\Master\Outlet\Edit as OutletEdit; use App\Livewire\Studio\Master\Outlet\Index as OutletIndex; @@ -125,5 +127,11 @@ Route::get('/{article}/edit', ArticleEdit::class)->name('edit')->middleware('can:update article'); Route::delete('/{article}/delete', ArticleCreate::class)->name('delete')->middleware('can:delete article'); }); + + Route::prefix('purchases')->name('purchase.')->group(function () { + Route::get('/', PurchaseIndex::class)->name('index')->middleware('can:view purchase'); + Route::get('/create', PurchaseCreate::class)->name('create')->middleware('can:create purchase'); + Route::delete('/{purchase}/delete', PurchaseCreate::class)->name('delete')->middleware('can:delete purchase'); + }); }); });