From 7a0ac4f4d10eece91646d8bd5e0c56315baac1fb Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 12 Jun 2026 10:34:50 +0700 Subject: [PATCH] Add order management features including Order and OrderItem models, OrderController for CRUD operations, and request validation. Implement OrderChannel and OrderStatus enums for order categorization and status management. Enhance sidebar navigation for orders and integrate UI components for creating, editing, and listing orders, along with permissions in the Permission and Role enums. --- app/Enums/OrderChannel.php | 32 ++ app/Enums/OrderStatus.php | 92 ++++ app/Enums/Permission.php | 19 + app/Enums/Role.php | 20 + .../Admin/Manage/OrderController.php | 107 ++++ .../Requests/Admin/Manage/OrderRequest.php | 63 +++ .../Manage/OrderStatusTransitionRequest.php | 50 ++ app/Models/Customer.php | 6 + app/Models/Order.php | 119 +++++ app/Models/OrderItem.php | 66 +++ app/Models/ProductVariant.php | 5 + app/Models/User.php | 5 + app/Observers/OrderObserver.php | 29 ++ app/Providers/AppServiceProvider.php | 8 + app/Services/Manage/OrderService.php | 369 +++++++++++++ .../2026_06_12_100001_create_orders_table.php | 51 ++ ..._06_12_100002_create_order_items_table.php | 34 ++ resources/js/components/AppSidebar.vue | 13 +- resources/js/components/ConfirmDialog.vue | 14 +- .../admin/manage/orders/OrderGroupedTable.vue | 198 +++++++ .../admin/manage/orders/OrderPosForm.vue | 492 ++++++++++++++++++ .../manage/orders/data-table-actions.vue | 187 +++++++ .../ui/alert-dialog/AlertDialogContent.vue | 4 +- .../js/components/ui/dialog/DialogContent.vue | 2 +- .../js/components/ui/dialog/DialogOverlay.vue | 2 +- resources/js/components/ui/sonner/Sonner.vue | 2 +- .../js/pages/admin/manage/orders/Create.vue | 46 ++ .../js/pages/admin/manage/orders/Edit.vue | 69 +++ .../js/pages/admin/manage/orders/Index.vue | 89 ++++ resources/js/types/order.ts | 111 ++++ routes/web.php | 30 ++ 31 files changed, 2318 insertions(+), 16 deletions(-) create mode 100644 app/Enums/OrderChannel.php create mode 100644 app/Enums/OrderStatus.php create mode 100644 app/Http/Controllers/Admin/Manage/OrderController.php create mode 100644 app/Http/Requests/Admin/Manage/OrderRequest.php create mode 100644 app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderItem.php create mode 100644 app/Observers/OrderObserver.php create mode 100644 app/Services/Manage/OrderService.php create mode 100644 database/migrations/2026_06_12_100001_create_orders_table.php create mode 100644 database/migrations/2026_06_12_100002_create_order_items_table.php create mode 100644 resources/js/components/admin/manage/orders/OrderGroupedTable.vue create mode 100644 resources/js/components/admin/manage/orders/OrderPosForm.vue create mode 100644 resources/js/components/admin/manage/orders/data-table-actions.vue create mode 100644 resources/js/pages/admin/manage/orders/Create.vue create mode 100644 resources/js/pages/admin/manage/orders/Edit.vue create mode 100644 resources/js/pages/admin/manage/orders/Index.vue create mode 100644 resources/js/types/order.ts diff --git a/app/Enums/OrderChannel.php b/app/Enums/OrderChannel.php new file mode 100644 index 0000000..e1c88cb --- /dev/null +++ b/app/Enums/OrderChannel.php @@ -0,0 +1,32 @@ + 'Toko', + self::SHOPEE => 'Shopee', + self::TIKTOK => 'TikTok', + }; + } + + public function defaultPriceType(): ?PriceType + { + return match ($this) { + self::STORE => null, + self::SHOPEE => PriceType::SHOPEE, + self::TIKTOK => PriceType::TIKTOK, + }; + } +} diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php new file mode 100644 index 0000000..570ed4e --- /dev/null +++ b/app/Enums/OrderStatus.php @@ -0,0 +1,92 @@ + 'Menunggu', + self::PROCESSING => 'Diproses', + self::COMPLETED => 'Selesai', + self::CANCELLED => 'Dibatalkan', + }; + } + + public function isEditable(): bool + { + return in_array($this, [self::PENDING, self::PROCESSING], true); + } + + public function canTransitionTo(self $status): bool + { + return match ($this) { + self::PENDING => in_array($status, [self::PROCESSING, self::CANCELLED], true), + self::PROCESSING => in_array($status, [self::COMPLETED, self::CANCELLED], true), + self::COMPLETED, self::CANCELLED => false, + }; + } + + public function transitionPermission(): Permission + { + return match ($this) { + self::PROCESSING => Permission::ORDERS_SEND, + self::COMPLETED => Permission::ORDERS_COMPLETE, + self::CANCELLED => Permission::ORDERS_CANCEL, + default => throw new InvalidArgumentException('Status tidak mendukung transisi.'), + }; + } + + /** + * @return list + */ + public function availableActions(): array + { + return match ($this) { + self::PENDING => [ + [ + 'status' => self::PROCESSING->value, + 'label' => 'Kirim', + 'destructive' => false, + 'permission' => Permission::ORDERS_SEND->value, + 'icon_only' => true, + ], + [ + 'status' => self::CANCELLED->value, + 'label' => 'Batalkan', + 'destructive' => true, + 'permission' => Permission::ORDERS_CANCEL->value, + 'icon_only' => true, + ], + ], + self::PROCESSING => [ + [ + 'status' => self::COMPLETED->value, + 'label' => 'Selesai', + 'destructive' => false, + 'permission' => Permission::ORDERS_COMPLETE->value, + 'icon_only' => false, + ], + [ + 'status' => self::CANCELLED->value, + 'label' => 'Batalkan', + 'destructive' => true, + 'permission' => Permission::ORDERS_CANCEL->value, + 'icon_only' => true, + ], + ], + default => [], + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index eb03a87..975a7c9 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -60,6 +60,14 @@ enum Permission: string case PURCHASES_UPDATE = 'purchases.update'; case PURCHASES_DELETE = 'purchases.delete'; + case ORDERS_VIEW = 'orders.view'; + case ORDERS_CREATE = 'orders.create'; + case ORDERS_UPDATE = 'orders.update'; + case ORDERS_DELETE = 'orders.delete'; + case ORDERS_SEND = 'orders.send'; + case ORDERS_COMPLETE = 'orders.complete'; + case ORDERS_CANCEL = 'orders.cancel'; + case CASH_VIEW = 'cash.view'; case CASH_DEPOSIT = 'cash.deposit'; case CASH_UPDATE = 'cash.update'; @@ -140,6 +148,14 @@ public function label(): string self::PURCHASES_UPDATE => 'Ubah Belanja', self::PURCHASES_DELETE => 'Hapus Belanja', + self::ORDERS_VIEW => 'Lihat Pesanan', + self::ORDERS_CREATE => 'Tambah Pesanan', + self::ORDERS_UPDATE => 'Ubah Pesanan', + self::ORDERS_DELETE => 'Hapus Pesanan', + self::ORDERS_SEND => 'Kirim Pesanan', + self::ORDERS_COMPLETE => 'Selesaikan Pesanan', + self::ORDERS_CANCEL => 'Batalkan Pesanan', + self::CASH_VIEW => 'Lihat Kas', self::CASH_DEPOSIT => 'Setor Kas', self::CASH_UPDATE => 'Ubah Setor Kas', @@ -189,6 +205,9 @@ public function group(): string self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku', self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE, self::PURCHASES_DELETE => 'Belanja', + self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE, + self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE, + self::ORDERS_CANCEL => 'Pesanan', self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas', self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE, self::EXPENSES_DELETE => 'Pengeluaran', diff --git a/app/Enums/Role.php b/app/Enums/Role.php index ee02d19..a1ca3c4 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -78,6 +78,13 @@ public function permissions(): array Permission::PURCHASES_CREATE, Permission::PURCHASES_UPDATE, Permission::PURCHASES_DELETE, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_DELETE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -128,6 +135,13 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_DELETE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -197,6 +211,12 @@ public function permissions(): array Permission::CUSTOMERS_UPDATE, Permission::CUSTOMERS_DELETE, Permission::PRODUCTS_VIEW, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, ], self::NON_OPERATOR => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Manage/OrderController.php b/app/Http/Controllers/Admin/Manage/OrderController.php new file mode 100644 index 0000000..06c271e --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/OrderController.php @@ -0,0 +1,107 @@ +parseDataTableQuery($request); + + return Inertia::render('admin/manage/orders/Index', [ + 'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/orders/Create', [ + 'customers' => $this->orderService->customerOptions(), + 'catalog' => $this->orderService->catalogItems(), + 'channels' => OrderChannel::selectOptions(), + 'storePriceTypes' => $this->orderService->storePriceTypeOptions(), + ]); + } + + public function store(OrderRequest $request): RedirectResponse + { + $this->orderService->create($request->validated(), $request->user()); + + Inertia::flash('success', 'Pesanan berhasil ditambahkan.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function edit(Order $order): Response|RedirectResponse + { + if (! $order->status->isEditable()) { + Inertia::flash('error', 'Pesanan tidak dapat diubah.'); + + return redirect()->route('admin.manage.orders.index'); + } + + return Inertia::render('admin/manage/orders/Edit', [ + 'order' => $this->orderService->findForEdit($order), + 'customers' => $this->orderService->customerOptions(), + 'catalog' => $this->orderService->catalogItems($order), + 'channels' => OrderChannel::selectOptions(), + 'storePriceTypes' => $this->orderService->storePriceTypeOptions(), + ]); + } + + public function update(OrderRequest $request, Order $order): RedirectResponse + { + $this->orderService->update($order, $request->validated()); + + Inertia::flash('success', 'Pesanan berhasil diperbarui.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function destroy(Order $order): RedirectResponse + { + $this->orderService->delete($order); + + Inertia::flash('success', 'Pesanan berhasil dihapus.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function transitionStatus(OrderStatusTransitionRequest $request, Order $order): RedirectResponse + { + $status = OrderStatus::from($request->validated('status')); + + $this->orderService->transitionStatus($order, $status); + + $message = match ($status) { + OrderStatus::PROCESSING => 'Pesanan berhasil dikirim.', + OrderStatus::COMPLETED => 'Pesanan berhasil diselesaikan.', + OrderStatus::CANCELLED => 'Pesanan berhasil dibatalkan.', + default => 'Status pesanan berhasil diperbarui.', + }; + + Inertia::flash('success', $message); + + return redirect()->route('admin.manage.orders.index'); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php new file mode 100644 index 0000000..d88ec04 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -0,0 +1,63 @@ +isMethod('POST') + ? Permission::ORDERS_CREATE + : Permission::ORDERS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + $rules = [ + 'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')->whereNull('deleted_at')], + 'channel' => ['required', 'string', Rule::in(array_column(OrderChannel::cases(), 'value'))], + 'price_type' => ['required', 'string', Rule::in(array_column(PriceType::cases(), 'value'))], + 'discount' => ['nullable', 'integer', 'min:0'], + 'marketplace_fee' => ['nullable', 'integer', 'min:0'], + 'notes' => ['nullable', 'string', 'max:100'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + ]; + + return $rules; + } + + public function withValidator(Validator $validator): void + { + if (! $this->isMethod('PUT') && ! $this->isMethod('PATCH')) { + return; + } + + $validator->after(function (Validator $validator): void { + /** @var Order $order */ + $order = $this->route('order'); + + if (! $order->status->isEditable()) { + $validator->errors()->add('status', 'Pesanan tidak dapat diubah.'); + } + }); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php b/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php new file mode 100644 index 0000000..1257d21 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php @@ -0,0 +1,50 @@ +input('status')); + + if ($status === null) { + return false; + } + + return $this->user()?->can($status->transitionPermission()->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'status' => ['required', 'string', Rule::in(array_column(OrderStatus::cases(), 'value'))], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Order $order */ + $order = $this->route('order'); + $status = OrderStatus::tryFrom((string) $this->input('status')); + + if ($status === null) { + return; + } + + if (! $order->status->canTransitionTo($status)) { + $validator->errors()->add('status', 'Status pesanan tidak dapat diubah.'); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index f8bd732..7b830c3 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -4,10 +4,16 @@ use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] class Customer extends Model { use SoftDeletes; + + 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..80804eb --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,119 @@ + OrderChannel::class, + 'price_type' => PriceType::class, + 'status' => OrderStatus::class, + 'subtotal' => 'integer', + 'discount' => 'integer', + 'marketplace_fee' => 'integer', + 'net_amount' => 'integer', + ]; + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function discountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'), + ); + } + + public function marketplaceFeeFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->marketplace_fee, 0, ',', '.'), + ); + } + + public function netAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function channelLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->channel->label(), + ); + } + + public function priceTypeLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->price_type->label(), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status->label(), + ); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..e797e23 --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,66 @@ + 'integer', + 'unit_price' => 'integer', + 'subtotal' => 'integer', + ]; + } + + public function quantityFormatted(): Attribute + { + return Attribute::make( + get: fn () => number_format($this->quantity, 0, ',', '.').' pcs', + ); + } + + public function quantityInput(): Attribute + { + return Attribute::make( + get: fn () => (string) $this->quantity, + ); + } + + public function unitPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'), + ); + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index c228d58..7cb6972 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -42,4 +42,9 @@ public function prices(): HasMany { return $this->hasMany(ProductPrice::class, 'variant_id'); } + + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 78c797b..8fc5481 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -80,4 +80,9 @@ public function purchases(): HasMany { return $this->hasMany(Purchase::class, 'created_by_id'); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class, 'created_by_id'); + } } diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php new file mode 100644 index 0000000..e5d13ec --- /dev/null +++ b/app/Observers/OrderObserver.php @@ -0,0 +1,29 @@ +order_number !== null && $order->order_number !== '') { + return; + } + + $date = now()->format('Ymd'); + $prefix = "PSN-{$date}-"; + + $lastOrder = Order::withTrashed() + ->where('order_number', 'like', "{$prefix}%") + ->orderByDesc('order_number') + ->value('order_number'); + + $sequence = $lastOrder !== null + ? ((int) substr($lastOrder, -5)) + 1 + : 1; + + $order->order_number = $prefix.str_pad((string) $sequence, 5, '0', STR_PAD_LEFT); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51747c7..3084cc8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Enums\Role; +use App\Models\Order; +use App\Observers\OrderObserver; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -27,6 +29,12 @@ public function boot(): void { $this->configureDefaults(); $this->configureAuthorization(); + $this->configureObservers(); + } + + protected function configureObservers(): void + { + Order::observe(OrderObserver::class); } protected function configureAuthorization(): void diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php new file mode 100644 index 0000000..e3acfda --- /dev/null +++ b/app/Services/Manage/OrderService.php @@ -0,0 +1,369 @@ +with([ + 'customer:id,name', + 'createdBy.profile', + 'items.productVariant.product:id,name', + 'items.productVariant:id,product_id,name', + ]) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->where('order_number', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%") + ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) + ->orWhereHas('items.productVariant', function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%") + ->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + }); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString() + ->through(function (Order $order) use ($user) { + $actions = collect($order->status->availableActions()) + ->filter(fn (array $action) => $user->can($action['permission'])) + ->values() + ->all(); + + $order->setAttribute('available_actions', $actions); + $order->setAttribute('is_editable', $order->status->isEditable()); + + return $order; + }); + } + + /** + * @return list + */ + public function customerOptions(): array + { + return Customer::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Customer $customer) => [ + 'value' => $customer->id, + 'label' => $customer->name, + ]) + ->all(); + } + + /** + * @return list + */ + public function storePriceTypeOptions(): array + { + return collect(PriceType::cases()) + ->reject(fn (PriceType $type) => in_array($type, [PriceType::SHOPEE, PriceType::TIKTOK], true)) + ->map(fn (PriceType $type) => [ + 'value' => $type->value, + 'label' => $type->label(), + ]) + ->values() + ->all(); + } + + /** + * @return Collection + */ + public function catalogItems(?Order $order = null): Collection + { + $orderVariantIds = $order + ? $order->items()->pluck('product_variant_id')->all() + : []; + + return Product::query() + ->with([ + 'variants' => fn ($query) => $query + ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->orderBy('created_at'), + ]) + ->where(function (Builder $query) use ($orderVariantIds): void { + $query->where('is_active', true); + + if ($orderVariantIds !== []) { + $query->orWhereHas( + 'variants', + fn (Builder $query) => $query->whereIn('id', $orderVariantIds), + ); + } + }) + ->orderBy('name') + ->get() + ->each(function (Product $product): void { + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + }); + }); + } + + public function findForEdit(Order $order): Order + { + $order->load([ + 'items.productVariant.product:id,name', + 'items.productVariant.prices', + 'items.productVariant.media', + ]); + + $order->items->each(function (OrderItem $item): void { + $variant = $item->productVariant; + + if ($variant) { + $item->setAttribute('product_name', $variant->product?->name); + $item->setAttribute('variant_name', $variant->name); + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + } + }); + + return $order; + } + + /** + * @param array $validated + */ + public function create(array $validated, User $user): Order + { + return DB::transaction(function () use ($validated, $user): Order { + $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); + $lineItems = $this->buildLineItems($validated['items'], $priceType); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); + $netAmount = max($subtotal - $discount - $marketplaceFee, 0); + + $order = Order::create([ + 'customer_id' => $validated['customer_id'] ?? null, + 'channel' => $validated['channel'], + 'price_type' => $priceType, + 'status' => OrderStatus::PENDING, + 'created_by_id' => $user->id, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'marketplace_fee' => $marketplaceFee, + 'net_amount' => $netAmount, + 'notes' => $validated['notes'] ?? null, + ]); + + foreach ($lineItems as $itemData) { + $orderItem = $order->items()->create($itemData); + $this->decrementStock($orderItem); + } + + return $order; + }); + } + + /** + * @param array $validated + */ + public function update(Order $order, array $validated): void + { + if (! $order->status->isEditable()) { + throw ValidationException::withMessages([ + 'status' => 'Pesanan tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($order, $validated): void { + $order->load('items'); + + foreach ($order->items as $item) { + $this->incrementStock($item); + } + + $order->items()->delete(); + + $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); + $lineItems = $this->buildLineItems($validated['items'], $priceType); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); + $netAmount = max($subtotal - $discount - $marketplaceFee, 0); + + $order->customer_id = $validated['customer_id'] ?? null; + $order->channel = $validated['channel']; + $order->price_type = $priceType; + $order->subtotal = $subtotal; + $order->discount = $discount; + $order->marketplace_fee = $marketplaceFee; + $order->net_amount = $netAmount; + $order->notes = $validated['notes'] ?? null; + $order->save(); + + foreach ($lineItems as $itemData) { + $orderItem = $order->items()->create($itemData); + $this->decrementStock($orderItem); + } + }); + } + + public function delete(Order $order): void + { + DB::transaction(function () use ($order): void { + $order->load('items'); + + if ($order->status->isEditable()) { + foreach ($order->items as $item) { + $this->incrementStock($item); + } + } + + $order->items()->delete(); + $order->delete(); + }); + } + + public function transitionStatus(Order $order, OrderStatus $status): void + { + if (! $order->status->canTransitionTo($status)) { + throw ValidationException::withMessages([ + 'status' => 'Status pesanan tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($order, $status): void { + if ($status === OrderStatus::CANCELLED) { + $order->load('items'); + + foreach ($order->items as $item) { + $this->incrementStock($item); + } + } + + $order->status = $status; + $order->save(); + }); + } + + /** + * @param list $items + * @return list + */ + private function buildLineItems(array $items, PriceType $priceType): array + { + return collect($items) + ->map(function (array $itemData, int $index) use ($priceType) { + $variant = ProductVariant::query()->find($itemData['product_variant_id']); + + if ($variant === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.', + ]); + } + + $price = ProductPrice::query() + ->where('variant_id', $variant->id) + ->where('type', $priceType) + ->first(); + + if ($price === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.', + ]); + } + + $quantity = (int) $itemData['quantity']; + + if ($quantity < 1) { + throw ValidationException::withMessages([ + "items.{$index}.quantity" => 'Jumlah minimal 1 pcs.', + ]); + } + + $unitPrice = (int) $price->price; + $subtotal = $unitPrice * $quantity; + + return [ + 'product_variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ]; + }) + ->all(); + } + + private function resolvePriceType(string $channel, string $priceType): PriceType + { + $channelEnum = OrderChannel::from($channel); + $priceTypeEnum = PriceType::from($priceType); + + $defaultPriceType = $channelEnum->defaultPriceType(); + + if ($defaultPriceType !== null && $priceTypeEnum !== $defaultPriceType) { + throw ValidationException::withMessages([ + 'price_type' => 'Tipe harga tidak sesuai dengan channel pesanan.', + ]); + } + + if ($channelEnum === OrderChannel::STORE && in_array($priceTypeEnum, [PriceType::SHOPEE, PriceType::TIKTOK], true)) { + throw ValidationException::withMessages([ + 'price_type' => 'Tipe harga marketplace tidak dapat digunakan untuk channel toko.', + ]); + } + + return $priceTypeEnum; + } + + private function decrementStock(OrderItem $item): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->decrement('stock', $item->quantity); + } + + private function incrementStock(OrderItem $item): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->increment('stock', $item->quantity); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'net_amount', 'discount', 'subtotal', 'order_number'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_12_100001_create_orders_table.php b/database/migrations/2026_06_12_100001_create_orders_table.php new file mode 100644 index 0000000..28aace8 --- /dev/null +++ b/database/migrations/2026_06_12_100001_create_orders_table.php @@ -0,0 +1,51 @@ +id(); + + $table->string('order_number', 30)->unique(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('channel', 20); + $table->string('price_type', 20); + $table->string('status', 20)->default('pending'); + + $table->unsignedBigInteger('subtotal'); + $table->unsignedBigInteger('discount')->default(0); + $table->unsignedBigInteger('marketplace_fee')->default(0); + $table->unsignedBigInteger('net_amount'); + + $table->text('notes')->nullable(); + + $table->foreignId('cash_transaction_id') + ->nullable() + ->unique() + ->constrained() + ->restrictOnDelete(); + + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + + $table->index('status'); + $table->index('customer_id'); + $table->index('channel'); + $table->index('price_type'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_06_12_100002_create_order_items_table.php b/database/migrations/2026_06_12_100002_create_order_items_table.php new file mode 100644 index 0000000..2cb47ed --- /dev/null +++ b/database/migrations/2026_06_12_100002_create_order_items_table.php @@ -0,0 +1,34 @@ +id(); + + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); + + $table->unsignedInteger('quantity'); + $table->unsignedBigInteger('unit_price'); + $table->unsignedBigInteger('subtotal'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['order_id', 'product_variant_id']); + $table->index('order_id'); + $table->index('product_variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 2da818f..fde7893 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@