feat(order): membuat crud

-skema migrasi dan menyesuaikan relasi model nya
-membuat beberapa trait utuk memisahkan logika
This commit is contained in:
Yoga Pangestu 2025-10-27 19:25:10 +07:00
parent 209be3fd25
commit d5fae1989a
28 changed files with 1533 additions and 0 deletions

View File

@ -0,0 +1,140 @@
<?php
namespace App\Livewire\Datatable\Studio\Manage;
use App\Models\Order;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use App\Traits\WithMediaHandler;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
class OrdersTable extends DataTableComponent
{
use WithConfiguration, WithMediaHandler, WithPrependColumn;
protected $model = Order::class;
public function columns(): array
{
return [
Column::make('Pegawai', 'user.employee.full_name')->searchable(),
Column::make('Nomor Faktur')
->label(function ($row) {
return <<<HTML
<div>
<div>{$row->invoice_number}</div>
<span class="text-xs text-gray-400">{$row?->customer?->name}</span>
</div>
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) => "
<div class='text-[13px] leading-tight mb-1'>
<div class='font-bold text-gray-800 dark:text-white'>{$value['name']}</div>
<div class='text-gray-400 text-[12px]'>{$value['quantity']} x {$value['unit_price']}</div>
<div class='text-gray-400 text-[12px]'>{$value['total_price']}</div>
</div>"
)
->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('
<div class="mt-2 text-start space-y-1">
<div>
<flux:text>{{ $subtotal }}</flux:text>
</div>
<div>
<flux:text class="text-xs italic">-{{ $discount }}</flux:text>
</div>
<flux:separator class="my-2 !bg-black dark:!bg-white" />
<div class="text-lg font-bold">
<flux:heading>{{ $total }}</flux:heading>
</div>
</div>
', ['value' => $value, 'subtotal' => $subtotal, 'discount' => $discount, 'total' => $total]);
})
->html(),
Column::make('Channel', 'channel')
->format(fn ($value) => Blade::render(
'<flux:badge color="'.$value->color().'">'
.$value->label()
.'</flux:badge>'
))
->html(),
Column::make('Status', 'status')
->format(fn ($value) => Blade::render(
'<flux:badge color="'.$value->color().'">'
.$value->label()
.'</flux:badge>'
))
->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']);
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Livewire\Forms\Studio\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Order;
use App\Models\Payment;
use App\Rules\UnsignedInteger;
use App\Traits\Order\WithOrderItem;
use App\Traits\Order\WithUpdateStock;
use App\Traits\WithMediaHandler;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class OrderForm extends Form
{
use WithMediaHandler, WithOrderItem, WithUpdateStock;
public ?Order $order = null;
public string $outlet_id = '';
public string $channel = '1';
public string $status = '4';
public string $payment_method = '1';
public string $perfume_id = '';
public string $bottle_id = '';
public string $quality_id = '';
public string $bottle_size = '';
public string $product_id = '';
public string $quantity_product = '';
public string $item_id = '';
public string $quantity_edit = '';
public string $discount = '';
public string $member_id = '';
public function rules(): array
{
return [
'outlet_id' => ['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,
]);
});
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Studio\Manage\Order;
use App\Livewire\Forms\Studio\Manage\OrderForm;
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
use App\Traits\Order\WithAddItem;
use App\Traits\Order\WithCalculateTotal;
use App\Traits\Order\WithDiscount;
use App\Traits\Order\WithManageItem;
use App\Traits\Order\WithMember;
use App\Traits\Order\WithOrderItem;
use App\Traits\Order\WithUpdateItem;
use App\Traits\WithAuthorization;
use App\Traits\WithConfirmation;
use App\Traits\WithToast;
use App\Traits\WithUpdatedData;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Tambah Order')]
class Create extends Component
{
use WithAddItem, WithAuthorization, WithCalculateTotal, WithConfirmation, WithDiscount, WithManageItem, WithMember, WithOrderItem, WithToast, WithUpdatedData, WithUpdateItem;
public OrderForm $form;
public array $outlets = [];
public array $perfumes = [];
public array $bottles = [];
public array $qualities = [];
public array $products = [];
public string $subtotal = '';
public string $total = '';
public $orderItems;
public function mount()
{
$this->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',
]);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Studio\Manage\Order;
use App\Models\Order;
use App\Traits\Order\WithUpdateStock;
use App\Traits\WithCloseModal;
use App\Traits\WithConfirmation;
use App\Traits\WithToast;
use Flux\Flux;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Order')]
class Index extends Component
{
use WithCloseModal, WithConfirmation, WithToast, WithUpdateStock;
public function delete(Order $order)
{
foreach ($order->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',
]);
}
}

View File

@ -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);
}
}

72
app/Models/Order.php Normal file
View File

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use Dyrynda\Database\Support\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
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;
class Order extends Model
{
use CascadeSoftDeletes, HasFactory, HashableId, SoftDeletes;
protected $guarded = ['id'];
protected $cascadeDeletes = ['items', 'payments'];
protected function casts(): array
{
return [
'cogs' => '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);
}
}

58
app/Models/OrderItem.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Dyrynda\Database\Support\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
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\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class OrderItem extends Model
{
use CascadeSoftDeletes, HasFactory, HashableId, SoftDeletes;
protected $guarded = ['id'];
protected $cascadeDeletes = ['volumes'];
protected function casts(): array
{
return [
'cogs' => '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);
}
}

View File

@ -81,4 +81,9 @@ public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}

32
app/Models/Payment.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'method' => 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);
}
}

View File

@ -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);
}
}

28
app/Models/VolumeItem.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class VolumeItem extends Model
{
use HasFactory, HashableId, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'volume' => 'int',
];
}
public function orderItem(): BelongsTo
{
return $this->belongsTo(OrderItem::class);
}
}

View File

@ -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);
}
}

View File

@ -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',

View File

@ -0,0 +1,152 @@
<?php
namespace App\Traits\Order;
use App\Models\Bottle;
use App\Models\Formula;
use App\Models\Perfume;
use App\Models\Product;
trait WithAddItem
{
public string $tab = 'new';
/**
* Triggered when the bottle ID form field is updated (used in "new" tab).
* Loads available qualities (Formula) based on the bottle size.
*/
public function updatedFormBottleId(string $value)
{
$bottle = Bottle::find($value);
if (! $bottle) {
$this->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']);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Traits\Order;
trait WithCalculateTotal
{
public function getSubTotal()
{
return $this->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);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Traits\Order;
trait WithDiscount
{
public function updatedFormDiscount(string $value)
{
$total = $this->getTotal();
$discount = replaceCurrency($value);
$this->total = $total - $discount;
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Traits\Order;
use App\Models\OrderItem;
trait WithManageItem
{
/**
* Adds a new order item or updates the quantity if it already exists in the order.
*
* @param mixed $model The model instance (Perfume, Bottle, or Product)
* @param int $quantity The quantity to add
*/
protected function addOrUpdateOrderItem($model, int $quantity)
{
$existing = $this->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.');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Traits\Order;
use App\Enums\UserStatus;
use App\Models\Customer;
use Livewire\Attributes\Computed;
trait WithMember
{
public string $searchMember = '';
#[Computed]
public function members()
{
if ($this->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();
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Traits\Order;
use App\Models\OrderItem;
trait WithOrderItem
{
public function loadOrderItems()
{
return $this->orderItems = OrderItem::query()
->currentUser()
->where('user_id', auth()->id())
->latest()
->get();
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Traits\Order;
use App\Models\OrderItem;
use Flux\Flux;
use Livewire\Attributes\On;
trait WithUpdateItem
{
public string $modalTitle = 'Ubah Item';
#[On('modal:open')]
public function openModal(OrderItem $item)
{
$this->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();
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Traits\Order;
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
trait WithUpdateStock
{
public function increaseOutletStock($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;
$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,
]);
}
}
}

View File

@ -0,0 +1,42 @@
<?php
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,37 @@
<?php
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('volume_items', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,320 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.manage.order.index') }}" wire:navigate.hover class="text-sm">
Kembali
</flux:button>
</div>
</div>
<div class="mt-6 space-y-6">
<flux:card class="space-y-6 p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<flux:field>
<flux:label>Outlet <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Outlet"
wire:model.live.debounce.500ms="form.outlet_id">
@foreach ($outlets as $key => $value)
<flux:select.option value="{{ $key }}">
{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.outlet_id" />
</flux:field>
<flux:field>
<flux:label>Channel <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Channel"
wire:model.live.debounce.500ms="form.channel">
@foreach (\App\Enums\OrderChannel::cases() as $channel)
<flux:select.option value="{{ $channel->value }}">{{ $channel->label() }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.channel" />
</flux:field>
<flux:field>
<flux:label>Status <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Status"
wire:model.live.debounce.500ms="form.status">
@foreach (\App\Enums\OrderStatus::cases() as $status)
<flux:select.option value="{{ $status->value }}">{{ $status->label() }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.status" />
</flux:field>
<flux:field>
<flux:label>Metode Pembayaran <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Metode Pembayaran"
wire:model.live.debounce.500ms="form.payment_method">
@foreach (\App\Enums\PaymentMethod::cases() as $paymentMethod)
<flux:select.option value="{{ $paymentMethod->value }}">
{{ $paymentMethod->label() }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.payment_method" />
</flux:field>
</div>
</flux:card>
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
<flux:card class="space-y-6 p-6 md:col-span-2">
<flux:tab.group>
<flux:tabs wire:model="tab" variant="pills">
<flux:tab name="new">Baru</flux:tab>
<flux:tab name="refill">Isi Ulang</flux:tab>
</flux:tabs>
<flux:tab.panel name="new">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:select label="Botol" variant="listbox" searchable placeholder="Cari botol..."
wire:model.live="form.bottle_id">
@foreach ($bottles as $key => $bottle)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">{{ $bottle }}
</flux:select.option>
@endforeach
</flux:select>
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<span class="text-sm text-zinc-500 italic">Silakan pilih botol...</span>
</flux:field>
@endif
</div>
</div>
</flux:tab.panel>
<flux:tab.panel name="refill">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:field>
<flux:label>Ukuran Botol</flux:label>
<flux:input.group>
<flux:input placeholder="Masukkan ukuran botol"
x-mask:dynamic="$money($input, ',')"
wire:model.live.debounce.500ms="form.bottle_size" autocomplete="off" />
<flux:input.group.suffix>ml</flux:input.group.suffix>
</flux:input.group>
</flux:field>
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<flux:text class="text-sm italic">Silakan masukan ukuran
botol...</flux:text>
</flux:field>
@endif
</div>
</div>
</flux:tab.panel>
</flux:tab.group>
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addPerfume">
Tambah
</flux:button>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:select label="Produk" variant="listbox" searchable placeholder="Cari produk..."
wire:model.live="form.product_id">
@foreach ($products as $key => $product)
<flux:select.option value="{{ $key }}"
wire:key="product-{{ $key }}">{{ $product }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas produk"
wire:model.live="form.quantity_product" autocomplete="off"
x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addProduct">
Tambah
</flux:button>
</div>
</flux:card>
</div>
</div>
<div class="w-full lg:w-1/3 space-y-4">
<flux:card class="space-y-6 p-6">
<h3 class="text-sm font-medium">Keranjang</h3>
<div class="divide-y">
<flux:table>
<flux:table.columns>
<flux:table.column>Item</flux:table.column>
<flux:table.column>Harga</flux:table.column>
<flux:table.column>Aksi</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($orderItems as $item)
<flux:table.row>
<flux:table.cell>
<flux:heading>
<div>
<div>{{ $item->orderable->name }}</div>
<span
class="text-xs text-gray-400">{{ currency($item->quantity, '') }}
x
{{ currency($item->unit_price, 'Rp') }}</span>
</div>
</flux:heading>
</flux:table.cell>
<flux:table.cell>
{{ currency($item->unit_price * $item->quantity, 'Rp') }}
</flux:table.cell>
<flux:table.cell class="space-x-2">
<flux:modal.trigger name="form-modal">
<flux:tooltip content="Ubah">
<flux:button variant="primary" color="yellow"
icon="pencil-square" size="sm"
wire:click="$dispatch('modal:open', {'item': '{{ $item->id }}'})">
</flux:button>
</flux:tooltip>
</flux:modal.trigger>
<flux:button variant="danger" icon="trash" size="sm"
wire:click="deleteItem('{{ $item->id }}')">
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="3" class="text-center">
<flux:text class="text-sm italic">Keranjang Kosong...
</flux:text>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<div class="mt-2 text-end space-y-1">
<div>
<flux:text>{{ currency($subtotal, 'Rp') }}</flux:text>
</div>
<div class="my-2">
<flux:field>
<flux:label></flux:label>
<flux:description class="text-xs italic">Kosongkan jika tidak ada.
</flux:description>
<flux:input.group>
<flux:input.group.prefix>Rp</flux:input.group.prefix>
<flux:input placeholder="Masukkan diskon" x-mask:dynamic="$money($input, ',')"
wire:model.live.debounce.500ms="form.discount" autocomplete="off" />
</flux:input.group>
</flux:field>
</div>
<div class="text-lg font-bold">
<flux:heading>
{{ currency($total, 'Rp') }}
</flux:heading>
</div>
</div>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:field>
<flux:label>Member</flux:label>
<flux:description>Silakan ketikkan nomor telepon.</flux:description>
<flux:select variant="combobox" placeholder="Pilih Member"
wire:model.live.debounce.500ms="form.member_id">
<x-slot name="input">
<flux:select.input wire:model.live.debounce.250ms="searchMember" />
</x-slot>
@foreach ($this->members as $member)
<flux:select.option value="{{ $member['id'] }}" key="{{ $member['id'] }}">
{{ $member['name'] }} - {{ $member['phone_number'] }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.member_id" />
</flux:field>
</flux:card>
</div>
</div>
<div class="flex justify-start mt-4">
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="save">
Simpan
</flux:button>
</div>
</div>
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="p-4 space-y-6">
<flux:heading size="lg">Ubah {{ $modalTitle }}</flux:heading>
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas" wire:model.live="form.quantity_edit"
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
<div class="flex">
<flux:spacer />
<flux:button variant="primary" color="zinc" class="sm:w-auto cursor-pointer"
wire:click="updateItem">
Perbarui
</flux:button>
</div>
</div>
</flux:modal>
</flux:main>

View File

@ -0,0 +1,21 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
@can('create order')
<div>
<flux:button href="{{ route('studio.manage.order.create') }}" variant="primary" wire:navigate.hover
class="text-sm">
Tambah
</flux:button>
</div>
@endcan
</div>
<div class="mt-6">
<livewire:datatable.studio.manage.orders-table />
</div>
@include('components.confirmation.delete')
</flux:main>

View File

@ -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');