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.

This commit is contained in:
Yoga Pangestu 2026-06-12 23:33:17 +07:00
parent b7f5c4e12e
commit 4c91834389
21 changed files with 794 additions and 56 deletions

View File

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

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\OrderDraftItemRequest;
use App\Http\Requests\Admin\Manage\OrderDraftResyncPricesRequest;
use App\Models\ProductVariant;
use App\Services\Manage\OrderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OrderDraftItemController extends Controller
{
public function __construct(
private readonly OrderService $orderService,
) {}
public function store(OrderDraftItemRequest $request): JsonResponse
{
$item = $this->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]);
}
}

View File

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

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest;
use App\Models\RawMaterialPrice;
use App\Services\Manage\PurchaseService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PurchaseDraftItemController extends Controller
{
public function __construct(
private readonly PurchaseService $purchaseService,
) {}
public function store(PurchaseDraftItemRequest $request): JsonResponse
{
$item = $this->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]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Enums\PriceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class OrderDraftItemRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::ORDERS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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)],
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Enums\PriceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class OrderDraftResyncPricesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::ORDERS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'price_type' => ['required', Rule::enum(PriceType::class)],
];
}
}

View File

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

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PurchaseDraftItemRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::PURCHASES_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@ -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;
}
/**

View File

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

View File

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

View File

@ -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<int, Product>
*/
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<array<string, mixed>>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
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<array<string, mixed>>
*/
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<string, mixed> $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<int, OrderItem> $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<OrderItem>
*/
private function draftItemsQuery(User $user): Builder
{
return OrderItem::query()
->whereNull('order_id')
->where('user_id', $user->id);
}
/**
* @return array<string, mixed>
*/
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<int, OrderItem> $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()

View File

@ -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<int, RawMaterial>
*/
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<array<string, mixed>>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
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<string, mixed> $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<int, PurchaseItem> $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<PurchaseItem>
*/
private function draftItemsQuery(User $user): Builder
{
return PurchaseItem::query()
->whereNull('purchase_id')
->where('user_id', $user->id);
}
/**
* @return array<string, mixed>
*/
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()

View File

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

View File

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

View File

@ -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<OrderCartItem[]>([]);
@ -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() {
<Minus class="size-3.5" />
</Button>
<Input v-model="item.quantity" type="number" min="1" step="1"
class="h-8 text-center" />
class="h-8 text-center" @change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />

View File

@ -34,6 +34,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 { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
@ -51,11 +52,13 @@ const props = defineProps<{
items: PurchaseCartItem[];
photos?: MediaItem | null;
};
draftItems?: PurchaseCartItem[];
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const isCreateMode = computed(() => props.method === 'post');
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const existingPhotoId = ref<number | null>(null);
@ -99,6 +102,31 @@ 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();
function upsertCartItem(item: PurchaseCartItem) {
const index = cart.value.findIndex(
(cartItem) => cartItem.raw_material_price_id === item.raw_material_price_id,
);
if (index === -1) {
cart.value.push({ ...item });
return;
}
cart.value[index] = { ...item };
}
type CatalogPrice = {
id: number;
variant: string;
@ -134,14 +162,36 @@ function lineSubtotal(item: PurchaseCartItem): number {
return Math.round(quantity * item.unit_price);
}
function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
async function syncDraftItem(priceId: number, quantity: number) {
const { item } = await apiFetch<{ item: PurchaseCartItem }>('/admin/manage/purchases/draft-items', {
method: 'POST',
body: JSON.stringify({
raw_material_price_id: priceId,
quantity,
}),
});
upsertCartItem(item);
}
async function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id,
);
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (isCreateMode.value) {
try {
await syncDraftItem(price.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;
}
@ -157,16 +207,40 @@ function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
});
}
function removeFromCart(index: number) {
async function removeFromCart(index: number) {
const item = cart.value[index];
if (isCreateMode.value) {
try {
await apiFetch(`/admin/manage/purchases/draft-items/${item.raw_material_price_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 <= 0) {
removeFromCart(index);
await removeFromCart(index);
return;
}
if (isCreateMode.value) {
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
return;
}
@ -174,6 +248,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 <= 0) {
await removeFromCart(index);
return;
}
if (!isCreateMode.value) {
return;
}
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
function buildFormData(): FormData {
const formData = new FormData();
@ -185,10 +280,12 @@ function buildFormData(): FormData {
formData.append('discount', parseRupiah(form.discount));
formData.append('notes', form.notes);
cart.value.forEach((item, index) => {
formData.append(`items[${index}][raw_material_price_id]`, String(item.raw_material_price_id));
formData.append(`items[${index}][quantity]`, item.quantity);
});
if (props.method === 'put') {
cart.value.forEach((item, index) => {
formData.append(`items[${index}][raw_material_price_id]`, String(item.raw_material_price_id));
formData.append(`items[${index}][quantity]`, item.quantity);
});
}
const removeMediaIds = [...form.remove_media_ids];
@ -353,7 +450,8 @@ function submit() {
<Minus class="size-3.5" />
</Button>
<Input v-model="item.quantity" type="number" min="0.0001"
step="0.0001" class="h-8 text-center" />
step="0.0001" class="h-8 text-center"
@change="syncCartItemQuantity(index)" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />

36
resources/js/lib/api.ts Normal file
View File

@ -0,0 +1,36 @@
function getXsrfToken(): string {
const match = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='));
return match ? decodeURIComponent(match.split('=')[1] ?? '') : '';
}
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getXsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
...options.headers,
},
credentials: 'same-origin',
});
if (!response.ok) {
const error = await response.json().catch(() => ({})) as {
message?: string;
errors?: Record<string, string[]>;
};
const firstValidationError = error.errors
? Object.values(error.errors).flat()[0]
: undefined;
throw new Error(firstValidationError ?? error.message ?? 'Permintaan gagal diproses.');
}
return response.json() as Promise<T>;
}

View File

@ -4,13 +4,14 @@ import { ArrowLeft } from '@lucide/vue';
import OrderPosForm from '@/components/admin/manage/orders/OrderPosForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, OrderCatalogItem, SelectOption } from '@/types/order';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
defineProps<{
customers: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
draftItems: OrderCartItem[];
}>();
</script>
@ -38,6 +39,7 @@ defineProps<{
:catalog="catalog"
:channels="channels"
:store-price-types="storePriceTypes"
:draft-items="draftItems"
submit-url="/admin/manage/orders"
method="post"
submit-label="Simpan"

View File

@ -4,11 +4,12 @@ import { ArrowLeft } from '@lucide/vue';
import PurchasePosForm from '@/components/admin/manage/purchases/PurchasePosForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PurchaseCatalogItem, SelectOption } from '@/types/purchase';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
draftItems: PurchaseCartItem[];
}>();
</script>
@ -34,6 +35,7 @@ defineProps<{
<PurchasePosForm
:suppliers="suppliers"
:catalog="catalog"
:draft-items="draftItems"
submit-url="/admin/manage/purchases"
method="post"
submit-label="Simpan"

View File

@ -15,7 +15,9 @@
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\CuttingController;
use App\Http\Controllers\Admin\Manage\OrderController;
use App\Http\Controllers\Admin\Manage\OrderDraftItemController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
@ -168,6 +170,14 @@
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('create');
Route::post('draft-items', [PurchaseDraftItemController::class, 'store'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('draft-items.store');
Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('draft-items.destroy');
Route::post('/', [PurchaseController::class, 'store'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('store');
@ -194,6 +204,18 @@
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('create');
Route::post('draft-items', [OrderDraftItemController::class, 'store'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('draft-items.store');
Route::put('draft-items/resync-prices', [OrderDraftItemController::class, 'resyncPrices'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('draft-items.resync-prices');
Route::delete('draft-items/{productVariant}', [OrderDraftItemController::class, 'destroy'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('draft-items.destroy');
Route::post('/', [OrderController::class, 'store'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('store');