From 4c91834389c4f0f764d0e074ad42877ecc58201b Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 12 Jun 2026 23:33:17 +0700 Subject: [PATCH] Enhance order and purchase management by implementing draft item functionality. Update OrderController and PurchaseController to handle draft items for users, allowing for better cart management. Refactor OrderService and PurchaseService to include methods for syncing and removing draft items. Update related request classes and models to support user associations. Modify frontend components to restore draft items and synchronize quantities, improving user experience during order and purchase creation. --- .../Admin/Manage/OrderController.php | 7 +- .../Admin/Manage/OrderDraftItemController.php | 42 ++++ .../Admin/Manage/PurchaseController.php | 7 +- .../Manage/PurchaseDraftItemController.php | 31 +++ .../Admin/Manage/OrderDraftItemRequest.php | 32 +++ .../Manage/OrderDraftResyncPricesRequest.php | 26 +++ .../Requests/Admin/Manage/OrderRequest.php | 12 +- .../Admin/Manage/PurchaseDraftItemRequest.php | 30 +++ .../Requests/Admin/Manage/PurchaseRequest.php | 18 +- app/Models/OrderItem.php | 5 + app/Models/PurchaseItem.php | 5 + app/Services/Manage/OrderService.php | 184 +++++++++++++++++- app/Services/Manage/PurchaseService.php | 115 ++++++++++- ..._11_120002_create_purchase_items_table.php | 3 +- ..._06_12_100002_create_order_items_table.php | 3 +- .../admin/manage/orders/OrderPosForm.vue | 144 ++++++++++++-- .../manage/purchases/PurchasePosForm.vue | 120 ++++++++++-- resources/js/lib/api.ts | 36 ++++ .../js/pages/admin/manage/orders/Create.vue | 4 +- .../pages/admin/manage/purchases/Create.vue | 4 +- routes/web.php | 22 +++ 21 files changed, 794 insertions(+), 56 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/OrderDraftItemController.php create mode 100644 app/Http/Controllers/Admin/Manage/PurchaseDraftItemController.php create mode 100644 app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php create mode 100644 app/Http/Requests/Admin/Manage/OrderDraftResyncPricesRequest.php create mode 100644 app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php create mode 100644 resources/js/lib/api.ts diff --git a/app/Http/Controllers/Admin/Manage/OrderController.php b/app/Http/Controllers/Admin/Manage/OrderController.php index b777a10..522d697 100644 --- a/app/Http/Controllers/Admin/Manage/OrderController.php +++ b/app/Http/Controllers/Admin/Manage/OrderController.php @@ -35,13 +35,16 @@ public function index(Request $request): Response ]); } - public function create(): Response + public function create(Request $request): Response { + $user = $request->user(); + return Inertia::render('admin/manage/orders/Create', [ 'customers' => $this->orderService->customerOptions(), - 'catalog' => $this->orderService->catalogItems(), + 'catalog' => $this->orderService->catalogItems(user: $user), 'channels' => OrderChannel::selectOptions(), 'storePriceTypes' => $this->orderService->storePriceTypeOptions(), + 'draftItems' => $this->orderService->draftItemsForUser($user), ]); } diff --git a/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php b/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php new file mode 100644 index 0000000..fb8b6af --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php @@ -0,0 +1,42 @@ +orderService->syncDraftItem($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + + public function destroy(Request $request, ProductVariant $productVariant): JsonResponse + { + $this->orderService->removeDraftItem($request->user(), $productVariant); + + return response()->json(['ok' => true]); + } + + public function resyncPrices(OrderDraftResyncPricesRequest $request): JsonResponse + { + $items = $this->orderService->resyncDraftPrices( + $request->user(), + $request->validated('price_type'), + ); + + return response()->json(['items' => $items]); + } +} diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php index 8b8fa76..d42ea67 100644 --- a/app/Http/Controllers/Admin/Manage/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -32,11 +32,14 @@ public function index(Request $request): Response ]); } - public function create(): Response + public function create(Request $request): Response { + $user = $request->user(); + return Inertia::render('admin/manage/purchases/Create', [ 'suppliers' => $this->purchaseService->supplierOptions(), - 'catalog' => $this->purchaseService->catalogItems(), + 'catalog' => $this->purchaseService->catalogItems(user: $user), + 'draftItems' => $this->purchaseService->draftItemsForUser($user), ]); } diff --git a/app/Http/Controllers/Admin/Manage/PurchaseDraftItemController.php b/app/Http/Controllers/Admin/Manage/PurchaseDraftItemController.php new file mode 100644 index 0000000..a392114 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/PurchaseDraftItemController.php @@ -0,0 +1,31 @@ +purchaseService->syncDraftItem($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + + public function destroy(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse + { + $this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php b/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php new file mode 100644 index 0000000..015d51a --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php @@ -0,0 +1,32 @@ +user()?->can(Permission::ORDERS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'quantity' => ['required', 'integer', 'min:1'], + 'price_type' => ['required', Rule::enum(PriceType::class)], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderDraftResyncPricesRequest.php b/app/Http/Requests/Admin/Manage/OrderDraftResyncPricesRequest.php new file mode 100644 index 0000000..ab9e422 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderDraftResyncPricesRequest.php @@ -0,0 +1,26 @@ +user()?->can(Permission::ORDERS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'price_type' => ['required', Rule::enum(PriceType::class)], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php index f7e0f81..b99b406 100644 --- a/app/Http/Requests/Admin/Manage/OrderRequest.php +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -33,15 +33,17 @@ public function rules(): array 'discount' => ['nullable', 'integer', 'min:0'], 'marketplace_fee' => ['nullable', 'integer', 'min:0'], 'notes' => ['nullable', 'string'], + ]; - 'items' => ['required', 'array', 'min:1'], - 'items.*.product_variant_id' => [ + if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { + $rules['items'] = ['required', 'array', 'min:1']; + $rules['items.*.product_variant_id'] = [ 'required', 'integer', Rule::exists('product_variants', 'id')->whereNull('deleted_at'), - ], - 'items.*.quantity' => ['required', 'integer', 'min:1'], - ]; + ]; + $rules['items.*.quantity'] = ['required', 'integer', 'min:1']; + } return $rules; } diff --git a/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php b/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php new file mode 100644 index 0000000..1837c29 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/PurchaseDraftItemRequest.php @@ -0,0 +1,30 @@ +user()?->can(Permission::PURCHASES_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'raw_material_price_id' => [ + 'required', + 'integer', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ], + 'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/PurchaseRequest.php b/app/Http/Requests/Admin/Manage/PurchaseRequest.php index beac7bb..dffb99f 100644 --- a/app/Http/Requests/Admin/Manage/PurchaseRequest.php +++ b/app/Http/Requests/Admin/Manage/PurchaseRequest.php @@ -25,20 +25,24 @@ public function authorize(): bool */ public function rules(): array { - return [ + $rules = [ 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')], 'discount' => ['nullable', 'integer', 'min:0'], 'notes' => ['nullable', 'string', 'max:100'], + ...$this->photoRules(), + ]; - 'items' => ['required', 'array', 'min:1'], - 'items.*.raw_material_price_id' => [ + if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { + $rules['items'] = ['required', 'array', 'min:1']; + $rules['items.*.raw_material_price_id'] = [ 'required', 'integer', Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), - ], - 'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], - ...$this->photoRules(), - ]; + ]; + $rules['items.*.quantity'] = ['required', 'numeric', 'decimal:0,4', 'gt:0']; + } + + return $rules; } /** diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 9a362ec..cc9a5d2 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -33,6 +33,11 @@ protected function casts(): array ]; } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function order(): BelongsTo { return $this->belongsTo(Order::class); diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php index cc21683..0ea4320 100644 --- a/app/Models/PurchaseItem.php +++ b/app/Models/PurchaseItem.php @@ -34,6 +34,11 @@ protected function casts(): array ]; } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function purchase(): BelongsTo { return $this->belongsTo(Purchase::class); diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index e3acfda..615dbd7 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -15,6 +15,7 @@ use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -97,11 +98,11 @@ public function storePriceTypeOptions(): array /** * @return Collection */ - public function catalogItems(?Order $order = null): Collection + public function catalogItems(?Order $order = null, ?User $user = null): Collection { $orderVariantIds = $order ? $order->items()->pluck('product_variant_id')->all() - : []; + : ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []); return Product::query() ->with([ @@ -155,6 +156,110 @@ public function findForEdit(Order $order): Order return $order; } + /** + * @return list> + */ + public function draftItemsForUser(User $user): array + { + return $this->draftItemsQuery($user) + ->with([ + 'productVariant.product:id,name', + 'productVariant.media', + ]) + ->get() + ->map(fn (OrderItem $item) => $this->presentDraftItem($item)) + ->values() + ->all(); + } + + /** + * @param array $validated + * @return array + */ + public function syncDraftItem(array $validated, User $user): array + { + $priceType = PriceType::from($validated['price_type']); + $variant = ProductVariant::query()->findOrFail($validated['product_variant_id']); + $price = ProductPrice::query() + ->where('variant_id', $variant->id) + ->where('type', $priceType) + ->first(); + + if ($price === null) { + throw ValidationException::withMessages([ + 'product_variant_id' => 'Harga untuk tipe harga ini belum diatur.', + ]); + } + + $quantity = (int) $validated['quantity']; + $unitPrice = (int) $price->price; + $subtotal = $unitPrice * $quantity; + + $item = OrderItem::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'product_variant_id' => $variant->id, + 'order_id' => null, + ], + [ + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ], + ); + + $item->load([ + 'productVariant.product:id,name', + 'productVariant.media', + ]); + + return $this->presentDraftItem($item); + } + + public function removeDraftItem(User $user, ProductVariant $productVariant): void + { + OrderItem::query() + ->whereNull('order_id') + ->where('user_id', $user->id) + ->where('product_variant_id', $productVariant->id) + ->delete(); + } + + /** + * @return list> + */ + public function resyncDraftPrices(User $user, string $priceTypeValue): array + { + $priceType = PriceType::from($priceTypeValue); + + $items = $this->draftItemsQuery($user) + ->with([ + 'productVariant.product:id,name', + 'productVariant.media', + ]) + ->get(); + + foreach ($items as $item) { + $price = ProductPrice::query() + ->where('variant_id', $item->product_variant_id) + ->where('type', $priceType) + ->first(); + + if ($price === null) { + $item->delete(); + + continue; + } + + $unitPrice = (int) $price->price; + $item->unit_price = $unitPrice; + $item->subtotal = $unitPrice * $item->quantity; + $item->save(); + } + + return $this->draftItemsForUser($user); + } + /** * @param array $validated */ @@ -162,8 +267,21 @@ 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')); + + /** @var EloquentCollection $draftItems */ + $draftItems = $this->draftItemsQuery($user) + ->lockForUpdate() + ->get(); + + if ($draftItems->isEmpty()) { + throw ValidationException::withMessages([ + 'items' => 'Tambahkan minimal satu produk ke keranjang.', + ]); + } + + $this->applyDraftPrices($draftItems, $priceType); + + $subtotal = $draftItems->sum('subtotal'); $discount = (int) ($validated['discount'] ?? 0); $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); $netAmount = max($subtotal - $discount - $marketplaceFee, 0); @@ -181,9 +299,10 @@ public function create(array $validated, User $user): Order 'notes' => $validated['notes'] ?? null, ]); - foreach ($lineItems as $itemData) { - $orderItem = $order->items()->create($itemData); - $this->decrementStock($orderItem); + foreach ($draftItems as $item) { + $item->order_id = $order->id; + $item->save(); + $this->decrementStock($item); } return $order; @@ -342,6 +461,57 @@ private function resolvePriceType(string $channel, string $priceType): PriceType return $priceTypeEnum; } + /** + * @return Builder + */ + private function draftItemsQuery(User $user): Builder + { + return OrderItem::query() + ->whereNull('order_id') + ->where('user_id', $user->id); + } + + /** + * @return array + */ + private function presentDraftItem(OrderItem $item): array + { + $variant = $item->productVariant; + + return [ + 'product_variant_id' => $item->product_variant_id, + 'product_name' => $variant?->product?->name ?? '', + 'variant_name' => $variant?->name ?? '', + 'quantity' => (string) $item->quantity, + 'unit_price' => $item->unit_price, + 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], + ]; + } + + /** + * @param EloquentCollection $items + */ + private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void + { + foreach ($items as $index => $item) { + $price = ProductPrice::query() + ->where('variant_id', $item->product_variant_id) + ->where('type', $priceType) + ->first(); + + if ($price === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.', + ]); + } + + $unitPrice = (int) $price->price; + $item->unit_price = $unitPrice; + $item->subtotal = $unitPrice * $item->quantity; + $item->save(); + } + } + private function decrementStock(OrderItem $item): void { ProductVariant::query() diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index bcb6b93..38cf835 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -12,6 +12,7 @@ use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -81,11 +82,11 @@ public function supplierOptions(): array /** * @return Collection */ - public function catalogItems(?Purchase $purchase = null): Collection + public function catalogItems(?Purchase $purchase = null, ?User $user = null): Collection { $purchasePriceIds = $purchase ? $purchase->items()->pluck('raw_material_price_id')->all() - : []; + : ($user ? $this->draftItemsQuery($user)->pluck('raw_material_price_id')->all() : []); return RawMaterial::query() ->with([ @@ -141,14 +142,84 @@ public function findForEdit(Purchase $purchase): Purchase return $purchase; } + /** + * @return list> + */ + public function draftItemsForUser(User $user): array + { + return $this->draftItemsQuery($user) + ->with([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]) + ->get() + ->map(fn (PurchaseItem $item) => $this->presentDraftItem($item)) + ->values() + ->all(); + } + + /** + * @param array $validated + * @return array + */ + public function syncDraftItem(array $validated, User $user): array + { + $price = RawMaterialPrice::query() + ->with('rawMaterial:id,name,unit') + ->findOrFail($validated['raw_material_price_id']); + + $quantity = (float) $validated['quantity']; + $unitPrice = (int) $price->price; + $subtotal = (int) round($quantity * $unitPrice); + + $item = PurchaseItem::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'raw_material_price_id' => $price->id, + 'purchase_id' => null, + ], + [ + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ], + ); + + $item->load([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]); + + return $this->presentDraftItem($item); + } + + public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void + { + PurchaseItem::query() + ->whereNull('purchase_id') + ->where('user_id', $user->id) + ->where('raw_material_price_id', $rawMaterialPrice->id) + ->delete(); + } + /** * @param array $validated */ public function create(array $validated, User $user): Purchase { return DB::transaction(function () use ($validated, $user): Purchase { - $lineItems = $this->buildLineItems($validated['items']); - $subtotal = array_sum(array_column($lineItems, 'subtotal')); + /** @var EloquentCollection $draftItems */ + $draftItems = $this->draftItemsQuery($user) + ->lockForUpdate() + ->get(); + + if ($draftItems->isEmpty()) { + throw ValidationException::withMessages([ + 'items' => 'Tambahkan minimal satu bahan baku ke keranjang.', + ]); + } + + $subtotal = $draftItems->sum('subtotal'); $discount = (int) ($validated['discount'] ?? 0); $total = max($subtotal - $discount, 0); @@ -161,9 +232,10 @@ public function create(array $validated, User $user): Purchase 'notes' => $validated['notes'] ?? null, ]); - foreach ($lineItems as $itemData) { - $purchaseItem = $purchase->items()->create($itemData); - $this->incrementStock($purchaseItem); + foreach ($draftItems as $item) { + $item->purchase_id = $purchase->id; + $item->save(); + $this->incrementStock($item); } $this->syncPhotos($purchase, $validated); @@ -268,6 +340,35 @@ private function syncPhotos(Purchase $purchase, array $validated): void ); } + /** + * @return Builder + */ + private function draftItemsQuery(User $user): Builder + { + return PurchaseItem::query() + ->whereNull('purchase_id') + ->where('user_id', $user->id); + } + + /** + * @return array + */ + private function presentDraftItem(PurchaseItem $item): array + { + $price = $item->rawMaterialPrice; + $rawMaterial = $price?->rawMaterial; + + return [ + 'raw_material_price_id' => $item->raw_material_price_id, + 'raw_material_name' => $rawMaterial?->name ?? '', + 'variant' => $price?->variant ?? '', + 'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '', + 'quantity' => $item->quantity_input, + 'unit_price' => $item->unit_price, + 'images' => $price ? MediaPresenter::collection($price, 'images') : [], + ]; + } + private function incrementStock(PurchaseItem $item): void { RawMaterialPrice::query() diff --git a/database/migrations/2026_06_11_120002_create_purchase_items_table.php b/database/migrations/2026_06_11_120002_create_purchase_items_table.php index 2c9b09e..6282625 100644 --- a/database/migrations/2026_06_11_120002_create_purchase_items_table.php +++ b/database/migrations/2026_06_11_120002_create_purchase_items_table.php @@ -11,7 +11,8 @@ public function up(): void Schema::create('purchase_items', function (Blueprint $table) { $table->id(); - $table->foreignId('purchase_id')->constrained()->cascadeOnDelete(); + $table->foreignId('purchase_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); $table->foreignId('raw_material_price_id')->constrained()->cascadeOnDelete(); $table->decimal('quantity', 18, 4); 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 index 17e9a4a..217af1b 100644 --- a/database/migrations/2026_06_12_100002_create_order_items_table.php +++ b/database/migrations/2026_06_12_100002_create_order_items_table.php @@ -11,7 +11,8 @@ public function up(): void Schema::create('order_items', function (Blueprint $table) { $table->id(); - $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); $table->unsignedInteger('quantity'); diff --git a/resources/js/components/admin/manage/orders/OrderPosForm.vue b/resources/js/components/admin/manage/orders/OrderPosForm.vue index 068af94..34dc97e 100644 --- a/resources/js/components/admin/manage/orders/OrderPosForm.vue +++ b/resources/js/components/admin/manage/orders/OrderPosForm.vue @@ -32,6 +32,7 @@ import { } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api'; import { getFirstCoverImage } from '@/lib/catalog-cover'; import { formErrors } from '@/lib/form'; import { formatRupiah, parseRupiah } from '@/lib/rupiah'; @@ -52,11 +53,14 @@ const props = defineProps<{ notes: string; items: OrderCartItem[]; }; + draftItems?: OrderCartItem[]; submitUrl: string; method: 'post' | 'put'; submitLabel: string; }>(); +const isCreateMode = computed(() => props.method === 'post'); + const search = ref(''); const cart = ref([]); @@ -94,6 +98,17 @@ watch( { immediate: true }, ); +function populateDraftItems() { + if (!isCreateMode.value || !props.draftItems?.length) { + return; + } + + cart.value = props.draftItems.map((item) => ({ ...item })); + toast.info('Keranjang dipulihkan dari data tersimpan.'); +} + +populateDraftItems(); + watch( () => form.channel, (channel) => { @@ -107,6 +122,43 @@ watch( }, ); +watch( + () => form.price_type, + async (priceType) => { + if (!isCreateMode.value || cart.value.length === 0) { + return; + } + + try { + const { items } = await apiFetch<{ items: OrderCartItem[] }>( + '/admin/manage/orders/draft-items/resync-prices', + { + method: 'PUT', + body: JSON.stringify({ price_type: priceType }), + }, + ); + + cart.value = items.map((item) => ({ ...item })); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui harga keranjang.'); + } + }, +); + +function upsertCartItem(item: OrderCartItem) { + const index = cart.value.findIndex( + (cartItem) => cartItem.product_variant_id === item.product_variant_id, + ); + + if (index === -1) { + cart.value.push({ ...item }); + + return; + } + + cart.value[index] = { ...item }; +} + const filteredCatalog = computed(() => { const keyword = search.value.trim().toLowerCase(); @@ -138,7 +190,20 @@ function lineSubtotal(item: OrderCartItem): number { return quantity * item.unit_price; } -function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) { +async function syncDraftItem(variantId: number, quantity: number) { + const { item } = await apiFetch<{ item: OrderCartItem }>('/admin/manage/orders/draft-items', { + method: 'POST', + body: JSON.stringify({ + product_variant_id: variantId, + quantity, + price_type: form.price_type, + }), + }); + + upsertCartItem(item); +} + +async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) { const price = getVariantPrice(variant); if (!price) { @@ -150,10 +215,20 @@ function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) { const existing = cart.value.find( (item) => item.product_variant_id === variant.id, ); + const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1; + + if (isCreateMode.value) { + try { + await syncDraftItem(variant.id, nextQty); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.'); + } + + return; + } if (existing) { - const currentQty = Number(existing.quantity) || 0; - existing.quantity = String(currentQty + 1); + existing.quantity = String(nextQty); return; } @@ -168,16 +243,40 @@ function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) { }); } -function removeFromCart(index: number) { +async function removeFromCart(index: number) { + const item = cart.value[index]; + + if (isCreateMode.value) { + try { + await apiFetch(`/admin/manage/orders/draft-items/${item.product_variant_id}`, { + method: 'DELETE', + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.'); + + return; + } + } + cart.value.splice(index, 1); } -function adjustQuantity(index: number, delta: number) { +async function adjustQuantity(index: number, delta: number) { const item = cart.value[index]; const nextQty = (Number(item.quantity) || 0) + delta; if (nextQty < 1) { - removeFromCart(index); + await removeFromCart(index); + + return; + } + + if (isCreateMode.value) { + try { + await syncDraftItem(item.product_variant_id, nextQty); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.'); + } return; } @@ -185,6 +284,27 @@ function adjustQuantity(index: number, delta: number) { item.quantity = String(nextQty); } +async function syncCartItemQuantity(index: number) { + const item = cart.value[index]; + const nextQty = Number(item.quantity) || 0; + + if (nextQty < 1) { + await removeFromCart(index); + + return; + } + + if (!isCreateMode.value) { + return; + } + + try { + await syncDraftItem(item.product_variant_id, nextQty); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.'); + } +} + function buildFormData(): FormData { const formData = new FormData(); @@ -202,10 +322,12 @@ function buildFormData(): FormData { formData.append('marketplace_fee', parseRupiah(form.marketplace_fee)); formData.append('notes', form.notes); - cart.value.forEach((item, index) => { - formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id)); - formData.append(`items[${index}][quantity]`, item.quantity); - }); + if (props.method === 'put') { + cart.value.forEach((item, index) => { + formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id)); + formData.append(`items[${index}][quantity]`, item.quantity); + }); + } return formData; } @@ -398,7 +520,7 @@ function submit() { + class="h-8 text-center" @change="syncCartItemQuantity(index)" /> + step="0.0001" class="h-8 text-center" + @change="syncCartItemQuantity(index)" />