From d5fae1989a746f1680612fe0c81b0867e0ad263d Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 27 Oct 2025 19:25:10 +0700 Subject: [PATCH] feat(order): membuat crud -skema migrasi dan menyesuaikan relasi model nya -membuat beberapa trait utuk memisahkan logika --- .../Datatable/Studio/Manage/OrdersTable.php | 140 ++++++++ .../Forms/Studio/Manage/OrderForm.php | 131 +++++++ app/Livewire/Studio/Manage/Order/Create.php | 93 +++++ app/Livewire/Studio/Manage/Order/Index.php | 40 +++ app/Models/Customer.php | 6 + app/Models/Order.php | 72 ++++ app/Models/OrderItem.php | 58 ++++ app/Models/Outlet.php | 5 + app/Models/Payment.php | 32 ++ app/Models/User.php | 15 + app/Models/VolumeItem.php | 28 ++ app/Models/Voucher.php | 6 + app/Providers/ViewServiceProvider.php | 7 + app/Traits/Order/WithAddItem.php | 152 +++++++++ app/Traits/Order/WithCalculateTotal.php | 16 + app/Traits/Order/WithDiscount.php | 14 + app/Traits/Order/WithManageItem.php | 53 +++ app/Traits/Order/WithMember.php | 30 ++ app/Traits/Order/WithOrderItem.php | 17 + app/Traits/Order/WithUpdateItem.php | 52 +++ app/Traits/Order/WithUpdateStock.php | 71 ++++ .../2025_10_06_135045_create_orders_table.php | 42 +++ ..._10_06_135049_create_order_items_table.php | 35 ++ ...025_10_06_140341_create_payments_table.php | 37 ++ ...10_08_070015_create_volume_items_table.php | 32 ++ .../studio/manage/order/form.blade.php | 320 ++++++++++++++++++ .../studio/manage/order/index.blade.php | 21 ++ routes/pages/studio.php | 8 + 28 files changed, 1533 insertions(+) create mode 100644 app/Livewire/Datatable/Studio/Manage/OrdersTable.php create mode 100644 app/Livewire/Forms/Studio/Manage/OrderForm.php create mode 100644 app/Livewire/Studio/Manage/Order/Create.php create mode 100644 app/Livewire/Studio/Manage/Order/Index.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderItem.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/VolumeItem.php create mode 100644 app/Traits/Order/WithAddItem.php create mode 100644 app/Traits/Order/WithCalculateTotal.php create mode 100644 app/Traits/Order/WithDiscount.php create mode 100644 app/Traits/Order/WithManageItem.php create mode 100644 app/Traits/Order/WithMember.php create mode 100644 app/Traits/Order/WithOrderItem.php create mode 100644 app/Traits/Order/WithUpdateItem.php create mode 100644 app/Traits/Order/WithUpdateStock.php create mode 100644 database/migrations/2025_10_06_135045_create_orders_table.php create mode 100644 database/migrations/2025_10_06_135049_create_order_items_table.php create mode 100644 database/migrations/2025_10_06_140341_create_payments_table.php create mode 100644 database/migrations/2025_10_08_070015_create_volume_items_table.php create mode 100644 resources/views/livewire/studio/manage/order/form.blade.php create mode 100644 resources/views/livewire/studio/manage/order/index.blade.php diff --git a/app/Livewire/Datatable/Studio/Manage/OrdersTable.php b/app/Livewire/Datatable/Studio/Manage/OrdersTable.php new file mode 100644 index 0000000..9df7960 --- /dev/null +++ b/app/Livewire/Datatable/Studio/Manage/OrdersTable.php @@ -0,0 +1,140 @@ +searchable(), + + Column::make('Nomor Faktur') + ->label(function ($row) { + return << +
{$row->invoice_number}
+ {$row?->customer?->name} + + HTML; + }) + ->searchable(function ($query, $searchTerm) { + $query->where('invoice_number', 'like', "%{$searchTerm}%") + ->orWhereHas('customer', function (Builder $query) use ($searchTerm) { + $query->where('name', 'like', "%{$searchTerm}%"); + }); + }) + ->html(), + + Column::make('Tanggal', 'created_at') + ->format(fn ($value) => formatDate($value)) + ->searchable(), + + Column::make('HPP', 'cogs') + ->format(fn ($value) => currency($value, 'Rp')) + ->searchable(), + + ArrayColumn::make('Item') + ->data( + fn ($value, $row) => $row->items->map(fn ($item) => [ + 'name' => $item->orderable?->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('Ringkasan') + ->label(function ($value) { + $subtotal = currency($value->subtotal, 'Rp'); + + $discount = currency($value->discount, 'Rp'); + + $total = currency($value->total, 'Rp'); + + return Blade::render(' +
+
+ {{ $subtotal }} +
+
+ -{{ $discount }} +
+ +
+ {{ $total }} +
+
+ ', ['value' => $value, 'subtotal' => $subtotal, 'discount' => $discount, 'total' => $total]); + }) + ->html(), + + Column::make('Channel', 'channel') + ->format(fn ($value) => Blade::render( + '' + .$value->label() + .'' + )) + ->html(), + + Column::make('Status', 'status') + ->format(fn ($value) => Blade::render( + '' + .$value->label() + .'' + )) + ->html(), + + Column::make('Aksi') + ->label(function ($row) { + $actions = ''; + + // if (auth()->user()->can('update order')) { + // $actions .= view('components.datatables.edit', [ + // 'id' => $row->hash, + // 'editRoute' => route('studio.manage.order.edit', $row->hash), + // ])->render(); + // } + + if (auth()->user()->can('delete order')) { + $actions .= view('components.datatables.delete', [ + 'id' => $row->hash, + 'deleteRoute' => route('studio.manage.order.delete', $row->hash), + ])->render(); + } + + return $actions; + }) + ->html(), + ]; + } + + public function builder(): Builder + { + return Order::select('orders.id', 'orders.user_id', 'invoice_number', 'customer_id', 'orders.created_at', 'cogs', 'subtotal', 'discount', 'total', 'orders.status', 'channel') + ->with(['user', 'user.employee', 'customer']); + } +} diff --git a/app/Livewire/Forms/Studio/Manage/OrderForm.php b/app/Livewire/Forms/Studio/Manage/OrderForm.php new file mode 100644 index 0000000..035454c --- /dev/null +++ b/app/Livewire/Forms/Studio/Manage/OrderForm.php @@ -0,0 +1,131 @@ + ['required', Rule::exists('outlets', 'id')], + 'channel' => ['required', Rule::in(OrderChannel::values())], + 'status' => ['required', Rule::in(OrderStatus::values())], + 'payment_method' => ['required', Rule::in(PaymentMethod::values())], + 'discount' => ['nullable', new UnsignedInteger], + 'member_id' => ['nullable', Rule::exists('customers', 'id')], + ]; + } + + public function validationAttributes(): array + { + return [ + 'payment_method' => 'metode pembayaran', + 'discount' => 'diskon', + 'member_id' => 'member', + ]; + } + + public function store() + { + $this->validate(); + + $orderItems = $this->loadOrderItems(); + + // Generate a unique invoice number based on today's paid order count + $todayCount = Order::whereDate('created_at', now())->paid()->count() + 1; + $invoiceNumber = 'INV'.now()->format('Ymd').str_pad($todayCount, 4, '0', STR_PAD_LEFT); + + // Calculate total cost of goods sold (COGS) — internal purchase cost + $cogs = $orderItems->sum(fn ($item) => $item->cogs * $item->quantity); + + // Calculate subtotal before any global discount + $subtotal = $orderItems->sum(fn ($item) => $item->unit_price * $item->quantity); + + $discount = replaceCurrency($this->discount); + + // Apply global discount to get final total + $total = $subtotal - $discount; + + // Wrap the entire operation in a database transaction + // Ensures atomicity — if any step fails, all changes are rolled back + DB::transaction(function () use ($invoiceNumber, $cogs, $subtotal, $discount, $total, $orderItems) { + $order = Order::create([ + 'outlet_id' => $this->outlet_id, + 'user_id' => auth()->id(), + 'customer_id' => $this->member_id == '' ? null : $this->member_id, + 'invoice_number' => $invoiceNumber, + 'cogs' => $cogs, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => $total, + 'channel' => $this->channel, + 'status' => $this->status, + 'payment_method' => $this->payment_method, + ]); + + // Attach all order items to the newly created order + $orderItems->each(function ($item) use ($order) { + $item->order_id = $order->id; + $item->save(); + + // Deduct item stock from the outlet inventory + $this->decreaseOutletStock($order->outlet, $item); + }); + + // Automatically record payment for the order + Payment::create([ + 'order_id' => $order->id, + 'user_id' => auth()->id(), + 'method' => $this->payment_method, + 'amount' => $total, + 'status' => PaymentStatus::PAID->value, + ]); + }); + } +} diff --git a/app/Livewire/Studio/Manage/Order/Create.php b/app/Livewire/Studio/Manage/Order/Create.php new file mode 100644 index 0000000..93b4c2e --- /dev/null +++ b/app/Livewire/Studio/Manage/Order/Create.php @@ -0,0 +1,93 @@ +outlets = auth()->user()->outlets->pluck('name', 'id')->toArray(); + + // Set default outlet if only one available + if (count($this->outlets) === 1) { + $this->form->outlet_id = key($this->outlets); + } + + $this->perfumes = Perfume::orderBy('name')->pluck('name', 'id')->toArray(); + + $this->bottles = Bottle::orderBy('name')->pluck('name', 'id')->toArray(); + + $this->products = Product::orderBy('name')->pluck('name', 'id')->toArray(); + + $this->orderItems = $this->loadOrderItems(); + + $this->subtotal = $this->getSubTotal(); + + $this->total = $this->getTotal(); + } + + public function save() + { + $this->canOrAbort('create order'); + + if ($this->orderItems->isEmpty()) { + $this->toast('Keranjang tidak boleh kosong.', 'Gagal', 'danger'); + + return; + } + + $this->form->store(); + + $this->dispatch('refreshDatatable'); + + $this->toast('Order berhasil ditambahkan.'); + + $this->redirectRoute('studio.manage.order.index', navigate: true); + } + + public function render() + { + return view('livewire.studio.manage.order.form', [ + 'pageTitle' => 'Tambah Order', + ]); + } +} diff --git a/app/Livewire/Studio/Manage/Order/Index.php b/app/Livewire/Studio/Manage/Order/Index.php new file mode 100644 index 0000000..4cac088 --- /dev/null +++ b/app/Livewire/Studio/Manage/Order/Index.php @@ -0,0 +1,40 @@ +items as $item) { + $this->decreaseOutletStock($order->outlet, $item); + } + + $order->delete(); + + $this->dispatch('refreshDatatable'); + + $this->toast('Order berhasil dihapus.'); + + Flux::modals()->close(); + } + + public function render() + { + return view('livewire.studio.manage.order.index', [ + 'pageTitle' => 'Order', + ]); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index fb9cefe..729aeb5 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Veelasky\LaravelHashId\Eloquent\HashableId; @@ -26,4 +27,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } } diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..be9ffb3 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,72 @@ + 'int', + 'subtotal' => 'int', + 'discount' => 'int', + 'total' => 'int', + 'channel' => OrderChannel::class, + 'status' => OrderStatus::class, + ]; + } + + #[Scope] + public function paid(Builder $query): void + { + $query->where('status', OrderStatus::PAID->value); + } + + public function outlet(): BelongsTo + { + return $this->belongsTo(Outlet::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function voucher(): BelongsTo + { + return $this->belongsTo(Voucher::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..f249d25 --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,58 @@ + 'int', + 'price' => 'int', + 'quantity' => 'int', + ]; + } + + #[Scope] + public function currentUser(Builder $query): void + { + $query->where('user_id', auth()->id()); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function orderable(): MorphTo + { + return $this->morphTo(); + } + + public function volumes(): HasMany + { + return $this->hasMany(VolumeItem::class); + } +} diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php index 29e40d9..a948972 100644 --- a/app/Models/Outlet.php +++ b/app/Models/Outlet.php @@ -81,4 +81,9 @@ public function purchases(): HasMany { return $this->hasMany(Purchase::class); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..eedc43b --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,32 @@ + PaymentMethod::class, + 'amount' => 'int', + 'payment' => PaymentStatus::class, + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a7c6b56..1841d5a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -81,4 +81,19 @@ public function payrolls(): HasMany { return $this->hasMany(Payroll::class); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } } diff --git a/app/Models/VolumeItem.php b/app/Models/VolumeItem.php new file mode 100644 index 0000000..4e2871a --- /dev/null +++ b/app/Models/VolumeItem.php @@ -0,0 +1,28 @@ + 'int', + ]; + } + + public function orderItem(): BelongsTo + { + return $this->belongsTo(OrderItem::class); + } +} diff --git a/app/Models/Voucher.php b/app/Models/Voucher.php index 735df56..a2c0664 100644 --- a/app/Models/Voucher.php +++ b/app/Models/Voucher.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\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Veelasky\LaravelHashId\Eloquent\HashableId; @@ -31,4 +32,9 @@ public function outlets(): BelongsToMany { return $this->belongsToMany(Outlet::class); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } } diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index e08c6b1..f88e69d 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -137,6 +137,13 @@ public function boot(): void [ 'heading' => 'Kelola', 'items' => [ + [ + 'label' => 'Order', + 'icon' => 'truck', + 'route' => 'studio.manage.order.index', + 'match' => 'studio.manage.order.*', + 'can' => 'view order', + ], [ 'label' => 'Artikel', 'icon' => 'newspaper', diff --git a/app/Traits/Order/WithAddItem.php b/app/Traits/Order/WithAddItem.php new file mode 100644 index 0000000..9ffbf9d --- /dev/null +++ b/app/Traits/Order/WithAddItem.php @@ -0,0 +1,152 @@ +qualities = []; + + return $this->toast('Botol tidak ditemukan, silakan hubungi Administrator.', 'Gagal', 'danger'); + } + + $formulas = Formula::where('size', $bottle->size)->pluck('quality', 'id')->toArray(); + + // check if there is any formula + if (empty($formulas)) { + $this->qualities = []; + $this->toast('Kualitas tidak ditemukan, silakan hubungi Administrator.', 'Gagal', 'danger'); + + return; + } + + $this->qualities = $formulas; + } + + /** + * Triggered when the bottle size form field is updated (used in "refill" tab). + * Loads available qualities (Formula) based on the selected size. + */ + public function updatedFormBottleSize(string $value) + { + $formulas = Formula::where('size', $value)->pluck('quality', 'id')->toArray(); + + // check if there is any formula + if (empty($formulas)) { + $this->qualities = []; + + return $this->toast('Kualitas tidak ditemukan, silakan hubungi Administrator.', 'Gagal', 'danger'); + } + + $this->qualities = $formulas; + } + + /** + * Entry point to add a perfume item to the cart. + * Determines the action based on the currently active tab. + */ + public function addPerfume() + { + match ($this->tab) { + 'new' => $this->handleNewPerfume(), + 'refill' => $this->handleRefillPerfume(), + default => $this->toast('Tab tidak dikenal, silakan periksa kembali.', 'Gagal', 'danger'), + }; + } + + /** + * Handles the process of adding a new perfume with a bottle and selected quality. + */ + protected function handleNewPerfume() + { + $perfume = Perfume::find($this->form->perfume_id); + $bottle = Bottle::find($this->form->bottle_id); + $quality = Formula::find($this->form->quality_id); + + if (! $perfume || ! $bottle || ! $quality) { + $this->toast('Parfum, botol, atau kualitas tidak valid.', 'Gagal', 'danger'); + + return; + } + + $quantity = $quality->volume; + + if ($quantity <= 0) { + $this->toast('Jumlah parfum tidak valid.', 'Gagal', 'danger'); + + return; + } + + $this->addOrUpdateOrderItem($perfume, $quantity); + + $this->addOrUpdateOrderItem($bottle, 1); + + $this->reset(['form.perfume_id', 'form.bottle_id', 'form.quality_id', 'form.bottle_size']); + + $this->qualities = []; + + $this->toast('Parfum dan botol baru ditambahkan ke keranjang.', 'Berhasil'); + } + + /** + * Handles the process of adding a refill perfume based on selected size and quality. + */ + protected function handleRefillPerfume() + { + $perfume = Perfume::find($this->form->perfume_id); + $quality = Formula::find($this->form->quality_id); + + $size = $this->form->bottle_size; + + if (! $perfume || ! $quality || $size <= 0) { + $this->toast('Parfum, ukuran, atau kualitas tidak valid.', 'Gagal', 'danger'); + + return; + } + + $quantity = $size; + + $this->addOrUpdateOrderItem($perfume, $quantity); + + $this->toast('Parfum isi ulang ditambahkan ke keranjang.', 'Berhasil'); + + $this->reset(['form.perfume_id', 'form.bottle_id', 'form.quality_id', 'form.bottle_size']); + + $this->qualities = []; + } + + /** + * Handles adding a regular product (non-perfume) to the cart. + */ + public function addProduct() + { + $product = Product::find($this->form->product_id); + + $quantity = replaceCurrency($this->form->quantity_product); + + if (! $product || $quantity <= 0) { + $this->toast('Produk atau kuantitas tidak valid', 'Gagal', 'danger'); + + return; + } + + $this->addOrUpdateOrderItem($product, $quantity); + + $this->reset(['form.product_id', 'form.quantity_product']); + } +} diff --git a/app/Traits/Order/WithCalculateTotal.php b/app/Traits/Order/WithCalculateTotal.php new file mode 100644 index 0000000..743665f --- /dev/null +++ b/app/Traits/Order/WithCalculateTotal.php @@ -0,0 +1,16 @@ +orderItems->sum(fn ($item) => $item->unit_price * $item->quantity); + } + + public function getTotal() + { + return $this->orderItems->sum(fn ($item) => $item->unit_price * $item->quantity - $item->discount); + } +} diff --git a/app/Traits/Order/WithDiscount.php b/app/Traits/Order/WithDiscount.php new file mode 100644 index 0000000..fffe509 --- /dev/null +++ b/app/Traits/Order/WithDiscount.php @@ -0,0 +1,14 @@ +getTotal(); + $discount = replaceCurrency($value); + + $this->total = $total - $discount; + } +} diff --git a/app/Traits/Order/WithManageItem.php b/app/Traits/Order/WithManageItem.php new file mode 100644 index 0000000..b69a730 --- /dev/null +++ b/app/Traits/Order/WithManageItem.php @@ -0,0 +1,53 @@ +orderItems->firstWhere(fn ($i) => $i->orderable_type === get_class($model) && $i->orderable_id === $model->id); + + if ($existing) { + $existing->increment('quantity', $quantity); + $existing->refresh(); + } else { + $existing = OrderItem::create([ + 'user_id' => auth()->id(), + 'orderable_id' => $model->id, + 'orderable_type' => get_class($model), + 'cogs' => $model->cost_price ?? 0, + 'quantity' => $quantity, + 'unit_price' => $model->sale_price ?? 0, + ]); + + $this->orderItems->push($existing); + } + + $this->orderItems = $this->orderItems->map(fn ($i) => $i->id === $existing->id ? $existing : $i); + + $this->subtotal = $this->getSubtotal(); + $this->total = $this->getTotal(); + } + + public function deleteItem(OrderItem $item) + { + $this->orderItems = $this->orderItems->reject(fn ($i) => $i->id === $item->id); + + $item->delete(); + + // Recalculate totals + $this->subtotal = $this->getSubTotal(); + $this->total = $this->getTotal(); + + $this->toast('Item berhasil dihapus.'); + } +} diff --git a/app/Traits/Order/WithMember.php b/app/Traits/Order/WithMember.php new file mode 100644 index 0000000..3ac40a9 --- /dev/null +++ b/app/Traits/Order/WithMember.php @@ -0,0 +1,30 @@ +searchMember === '') { + return []; + } + + return Customer::whereHas('user', fn ($q) => $q->where('status', UserStatus::ACTIVE)) + ->where('phone_number', 'like', '%'.$this->searchMember.'%') + ->get() + ->map(fn ($customer) => [ + 'id' => $customer->id, + 'name' => $customer?->name, + 'phone_number' => $customer?->phone_number, + ]) + ->toArray(); + } +} diff --git a/app/Traits/Order/WithOrderItem.php b/app/Traits/Order/WithOrderItem.php new file mode 100644 index 0000000..b414857 --- /dev/null +++ b/app/Traits/Order/WithOrderItem.php @@ -0,0 +1,17 @@ +orderItems = OrderItem::query() + ->currentUser() + ->where('user_id', auth()->id()) + ->latest() + ->get(); + } +} diff --git a/app/Traits/Order/WithUpdateItem.php b/app/Traits/Order/WithUpdateItem.php new file mode 100644 index 0000000..6ca5f48 --- /dev/null +++ b/app/Traits/Order/WithUpdateItem.php @@ -0,0 +1,52 @@ +modalTitle = $item->orderable->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 = OrderItem::find($this->form->item_id); + + if (! $item) { + $this->toast('Item tidak ditemukan. Silakan coba lagi.', 'Gagal', 'danger'); + + return; + } + + $item->update(['quantity' => replaceCurrency($this->form->quantity_edit)]); + + $item->refresh(); + + $this->orderItems = $this->orderItems->map(fn ($i) => $i->id === $item->id ? $item : $i); + + $this->subtotal = $this->getSubTotal(); + + $this->total = $this->getTotal(); + + $this->toast('Item berhasil diperbarui.'); + + Flux::modals('form-modal')->close(); + } +} diff --git a/app/Traits/Order/WithUpdateStock.php b/app/Traits/Order/WithUpdateStock.php new file mode 100644 index 0000000..89558b6 --- /dev/null +++ b/app/Traits/Order/WithUpdateStock.php @@ -0,0 +1,71 @@ +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, + ]); + } + } + + public function decreaseOutletStock($outlet, $item) + { + $quantity = $item->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; + $newStock = max(0, $currentStock - $quantity); + $outlet->{$relation}()->updateExistingPivot($item->purchasable_id, [ + 'stock' => $newStock, + ]); + } + } +} diff --git a/database/migrations/2025_10_06_135045_create_orders_table.php b/database/migrations/2025_10_06_135045_create_orders_table.php new file mode 100644 index 0000000..49363b4 --- /dev/null +++ b/database/migrations/2025_10_06_135045_create_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('outlet_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('voucher_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('invoice_number', 50); + $table->unsignedInteger('cogs'); + $table->unsignedInteger('subtotal'); + $table->unsignedInteger('discount'); + $table->unsignedInteger('total'); + $table->enum('channel', [OrderChannel::values()])->default(OrderChannel::OUTLET)->comment(OrderChannel::comment()); + $table->enum('status', [OrderStatus::values()])->default(OrderStatus::PENDING)->comment(OrderStatus::comment()); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2025_10_06_135049_create_order_items_table.php b/database/migrations/2025_10_06_135049_create_order_items_table.php new file mode 100644 index 0000000..dcc262c --- /dev/null +++ b/database/migrations/2025_10_06_135049_create_order_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('order_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('orderable'); + $table->unsignedInteger('cogs'); + $table->unsignedInteger('unit_price'); + $table->unsignedInteger('quantity'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; diff --git a/database/migrations/2025_10_06_140341_create_payments_table.php b/database/migrations/2025_10_06_140341_create_payments_table.php new file mode 100644 index 0000000..ef63c47 --- /dev/null +++ b/database/migrations/2025_10_06_140341_create_payments_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('method', [PaymentMethod::values()])->default(PaymentMethod::CASH)->comment(PaymentMethod::comment()); + $table->unsignedInteger('amount'); + $table->timestamp('paid_at')->useCurrent(); + $table->enum('status', [PaymentStatus::values()])->default(PaymentStatus::PENDING)->comment(PaymentStatus::comment()); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2025_10_08_070015_create_volume_items_table.php b/database/migrations/2025_10_08_070015_create_volume_items_table.php new file mode 100644 index 0000000..105a56d --- /dev/null +++ b/database/migrations/2025_10_08_070015_create_volume_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('order_item_id')->constrained()->cascadeOnDelete(); + $table->string('quality', 20); + $table->unsignedInteger('volume'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('volume_items'); + } +}; diff --git a/resources/views/livewire/studio/manage/order/form.blade.php b/resources/views/livewire/studio/manage/order/form.blade.php new file mode 100644 index 0000000..56f2118 --- /dev/null +++ b/resources/views/livewire/studio/manage/order/form.blade.php @@ -0,0 +1,320 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+ +
+ + Outlet * + + @foreach ($outlets as $key => $value) + + {{ $value }} + + @endforeach + + + + + + Channel * + + @foreach (\App\Enums\OrderChannel::cases() as $channel) + {{ $channel->label() }} + + @endforeach + + + + + + Status * + + @foreach (\App\Enums\OrderStatus::cases() as $status) + {{ $status->label() }} + + @endforeach + + + + + + Metode Pembayaran * + + @foreach (\App\Enums\PaymentMethod::cases() as $paymentMethod) + + {{ $paymentMethod->label() }} + + @endforeach + + + +
+
+ +
+
+
+ + + + Baru + Isi Ulang + + + +
+ + @foreach ($perfumes as $key => $perfume) + + {{ $perfume }} + + @endforeach + + + + @foreach ($bottles as $key => $bottle) + {{ $bottle }} + + @endforeach + + +
+ @if (!empty($qualities)) + + @foreach ($qualities as $key => $value) + + {{ $value }} + + @endforeach + + @else + + Kualitas +
+ Silakan pilih botol... +
+ @endif +
+
+
+ + +
+ + @foreach ($perfumes as $key => $perfume) + + {{ $perfume }} + + @endforeach + + + + Ukuran Botol + + + ml + + + +
+ @if (!empty($qualities)) + + @foreach ($qualities as $key => $value) + + {{ $value }} + + @endforeach + + @else + + Kualitas +
+ Silakan masukan ukuran + botol... +
+ @endif +
+
+
+
+ +
+ + Tambah + +
+
+ + + + @foreach ($products as $key => $product) + {{ $product }} + + @endforeach + + + + +
+ + Tambah + +
+
+
+
+ +
+ +

Keranjang

+
+ + + Item + Harga + Aksi + + + + @forelse($orderItems as $item) + + + +
+
{{ $item->orderable->name }}
+ {{ currency($item->quantity, '') }} + x + {{ currency($item->unit_price, 'Rp') }} +
+
+
+ + {{ currency($item->unit_price * $item->quantity, 'Rp') }} + + + + + + + + + + + + +
+ @empty + + + Keranjang Kosong... + + + + @endforelse +
+
+ +
+
+ {{ currency($subtotal, 'Rp') }} +
+
+ + + Kosongkan jika tidak ada. + + + Rp + + + +
+
+ + {{ currency($total, 'Rp') }} + +
+
+
+
+ + + + Member + Silakan ketikkan nomor telepon. + + + + + @foreach ($this->members as $member) + + {{ $member['name'] }} - {{ $member['phone_number'] }} + + @endforeach + + + + +
+
+ +
+ + Simpan + +
+
+ + +
+ Ubah {{ $modalTitle }} + + + +
+ + + Perbarui + +
+
+
+
diff --git a/resources/views/livewire/studio/manage/order/index.blade.php b/resources/views/livewire/studio/manage/order/index.blade.php new file mode 100644 index 0000000..3079d31 --- /dev/null +++ b/resources/views/livewire/studio/manage/order/index.blade.php @@ -0,0 +1,21 @@ + +
+
+ {{ $pageTitle }} +
+ @can('create order') +
+ + Tambah + +
+ @endcan +
+ +
+ +
+ + @include('components.confirmation.delete') +
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index fb3dff7..8812d9b 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -22,6 +22,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\Order\Create as OrderCreate; +use App\Livewire\Studio\Manage\Order\Index as OrderIndex; use App\Livewire\Studio\Manage\Purchase\Create as PurchaseCreate; use App\Livewire\Studio\Manage\Purchase\Index as PurchaseIndex; use App\Livewire\Studio\Master\Formula as FormulaComponent; @@ -136,6 +138,12 @@ }); Route::prefix('manage')->name('studio.manage.')->group(function () { + Route::prefix('orders')->name('order.')->group(function () { + Route::get('/', OrderIndex::class)->name('index')->middleware('can:view order'); + Route::get('/create', OrderCreate::class)->name('create')->middleware('can:create order'); + Route::delete('/{order}/delete', OrderCreate::class)->name('delete')->middleware('can:delete order'); + }); + Route::prefix('articles')->name('article.')->group(function () { Route::get('/', ArticleIndex::class)->name('index')->middleware('can:view article'); Route::get('/create', ArticleCreate::class)->name('create')->middleware('can:create article');