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', [ return Inertia::render('admin/manage/orders/Create', [
'customers' => $this->orderService->customerOptions(), 'customers' => $this->orderService->customerOptions(),
'catalog' => $this->orderService->catalogItems(), 'catalog' => $this->orderService->catalogItems(user: $user),
'channels' => OrderChannel::selectOptions(), 'channels' => OrderChannel::selectOptions(),
'storePriceTypes' => $this->orderService->storePriceTypeOptions(), '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', [ return Inertia::render('admin/manage/purchases/Create', [
'suppliers' => $this->purchaseService->supplierOptions(), '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'], 'discount' => ['nullable', 'integer', 'min:0'],
'marketplace_fee' => ['nullable', 'integer', 'min:0'], 'marketplace_fee' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
];
'items' => ['required', 'array', 'min:1'], if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
'items.*.product_variant_id' => [ $rules['items'] = ['required', 'array', 'min:1'];
$rules['items.*.product_variant_id'] = [
'required', 'required',
'integer', 'integer',
Rule::exists('product_variants', 'id')->whereNull('deleted_at'), Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
], ];
'items.*.quantity' => ['required', 'integer', 'min:1'], $rules['items.*.quantity'] = ['required', 'integer', 'min:1'];
]; }
return $rules; 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 public function rules(): array
{ {
return [ $rules = [
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')], 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:100'],
...$this->photoRules(),
];
'items' => ['required', 'array', 'min:1'], if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
'items.*.raw_material_price_id' => [ $rules['items'] = ['required', 'array', 'min:1'];
$rules['items.*.raw_material_price_id'] = [
'required', 'required',
'integer', 'integer',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
], ];
'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], $rules['items.*.quantity'] = ['required', 'numeric', 'decimal:0,4', 'gt:0'];
...$this->photoRules(), }
];
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 public function order(): BelongsTo
{ {
return $this->belongsTo(Order::class); 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 public function purchase(): BelongsTo
{ {
return $this->belongsTo(Purchase::class); return $this->belongsTo(Purchase::class);

View File

@ -15,6 +15,7 @@
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -97,11 +98,11 @@ public function storePriceTypeOptions(): array
/** /**
* @return Collection<int, Product> * @return Collection<int, Product>
*/ */
public function catalogItems(?Order $order = null): Collection public function catalogItems(?Order $order = null, ?User $user = null): Collection
{ {
$orderVariantIds = $order $orderVariantIds = $order
? $order->items()->pluck('product_variant_id')->all() ? $order->items()->pluck('product_variant_id')->all()
: []; : ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []);
return Product::query() return Product::query()
->with([ ->with([
@ -155,6 +156,110 @@ public function findForEdit(Order $order): Order
return $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 * @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 { return DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); $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); $discount = (int) ($validated['discount'] ?? 0);
$marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0);
$netAmount = max($subtotal - $discount - $marketplaceFee, 0); $netAmount = max($subtotal - $discount - $marketplaceFee, 0);
@ -181,9 +299,10 @@ public function create(array $validated, User $user): Order
'notes' => $validated['notes'] ?? null, 'notes' => $validated['notes'] ?? null,
]); ]);
foreach ($lineItems as $itemData) { foreach ($draftItems as $item) {
$orderItem = $order->items()->create($itemData); $item->order_id = $order->id;
$this->decrementStock($orderItem); $item->save();
$this->decrementStock($item);
} }
return $order; return $order;
@ -342,6 +461,57 @@ private function resolvePriceType(string $channel, string $priceType): PriceType
return $priceTypeEnum; 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 private function decrementStock(OrderItem $item): void
{ {
ProductVariant::query() ProductVariant::query()

View File

@ -12,6 +12,7 @@
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -81,11 +82,11 @@ public function supplierOptions(): array
/** /**
* @return Collection<int, RawMaterial> * @return Collection<int, RawMaterial>
*/ */
public function catalogItems(?Purchase $purchase = null): Collection public function catalogItems(?Purchase $purchase = null, ?User $user = null): Collection
{ {
$purchasePriceIds = $purchase $purchasePriceIds = $purchase
? $purchase->items()->pluck('raw_material_price_id')->all() ? $purchase->items()->pluck('raw_material_price_id')->all()
: []; : ($user ? $this->draftItemsQuery($user)->pluck('raw_material_price_id')->all() : []);
return RawMaterial::query() return RawMaterial::query()
->with([ ->with([
@ -141,14 +142,84 @@ public function findForEdit(Purchase $purchase): Purchase
return $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 * @param array<string, mixed> $validated
*/ */
public function create(array $validated, User $user): Purchase public function create(array $validated, User $user): Purchase
{ {
return DB::transaction(function () use ($validated, $user): Purchase { return DB::transaction(function () use ($validated, $user): Purchase {
$lineItems = $this->buildLineItems($validated['items']); /** @var EloquentCollection<int, PurchaseItem> $draftItems */
$subtotal = array_sum(array_column($lineItems, 'subtotal')); $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); $discount = (int) ($validated['discount'] ?? 0);
$total = max($subtotal - $discount, 0); $total = max($subtotal - $discount, 0);
@ -161,9 +232,10 @@ public function create(array $validated, User $user): Purchase
'notes' => $validated['notes'] ?? null, 'notes' => $validated['notes'] ?? null,
]); ]);
foreach ($lineItems as $itemData) { foreach ($draftItems as $item) {
$purchaseItem = $purchase->items()->create($itemData); $item->purchase_id = $purchase->id;
$this->incrementStock($purchaseItem); $item->save();
$this->incrementStock($item);
} }
$this->syncPhotos($purchase, $validated); $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 private function incrementStock(PurchaseItem $item): void
{ {
RawMaterialPrice::query() RawMaterialPrice::query()

View File

@ -11,7 +11,8 @@ public function up(): void
Schema::create('purchase_items', function (Blueprint $table) { Schema::create('purchase_items', function (Blueprint $table) {
$table->id(); $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->foreignId('raw_material_price_id')->constrained()->cascadeOnDelete();
$table->decimal('quantity', 18, 4); $table->decimal('quantity', 18, 4);

View File

@ -11,7 +11,8 @@ public function up(): void
Schema::create('order_items', function (Blueprint $table) { Schema::create('order_items', function (Blueprint $table) {
$table->id(); $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->foreignId('product_variant_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity'); $table->unsignedInteger('quantity');

View File

@ -32,6 +32,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover'; import { getFirstCoverImage } from '@/lib/catalog-cover';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah'; import { formatRupiah, parseRupiah } from '@/lib/rupiah';
@ -52,11 +53,14 @@ const props = defineProps<{
notes: string; notes: string;
items: OrderCartItem[]; items: OrderCartItem[];
}; };
draftItems?: OrderCartItem[];
submitUrl: string; submitUrl: string;
method: 'post' | 'put'; method: 'post' | 'put';
submitLabel: string; submitLabel: string;
}>(); }>();
const isCreateMode = computed(() => props.method === 'post');
const search = ref(''); const search = ref('');
const cart = ref<OrderCartItem[]>([]); const cart = ref<OrderCartItem[]>([]);
@ -94,6 +98,17 @@ watch(
{ immediate: true }, { 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( watch(
() => form.channel, () => form.channel,
(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 filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase(); const keyword = search.value.trim().toLowerCase();
@ -138,7 +190,20 @@ function lineSubtotal(item: OrderCartItem): number {
return quantity * item.unit_price; 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); const price = getVariantPrice(variant);
if (!price) { if (!price) {
@ -150,10 +215,20 @@ function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) {
const existing = cart.value.find( const existing = cart.value.find(
(item) => item.product_variant_id === variant.id, (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) { if (existing) {
const currentQty = Number(existing.quantity) || 0; existing.quantity = String(nextQty);
existing.quantity = String(currentQty + 1);
return; 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); cart.value.splice(index, 1);
} }
function adjustQuantity(index: number, delta: number) { async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index]; const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta; const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty < 1) { 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; return;
} }
@ -185,6 +284,27 @@ function adjustQuantity(index: number, delta: number) {
item.quantity = String(nextQty); 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 { function buildFormData(): FormData {
const formData = new FormData(); const formData = new FormData();
@ -202,10 +322,12 @@ function buildFormData(): FormData {
formData.append('marketplace_fee', parseRupiah(form.marketplace_fee)); formData.append('marketplace_fee', parseRupiah(form.marketplace_fee));
formData.append('notes', form.notes); formData.append('notes', form.notes);
cart.value.forEach((item, index) => { if (props.method === 'put') {
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id)); cart.value.forEach((item, index) => {
formData.append(`items[${index}][quantity]`, item.quantity); formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
}); formData.append(`items[${index}][quantity]`, item.quantity);
});
}
return formData; return formData;
} }
@ -398,7 +520,7 @@ function submit() {
<Minus class="size-3.5" /> <Minus class="size-3.5" />
</Button> </Button>
<Input v-model="item.quantity" type="number" min="1" step="1" <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" <Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)"> class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" /> <Plus class="size-3.5" />

View File

@ -34,6 +34,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api';
import { getFirstCoverImage } from '@/lib/catalog-cover'; import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
@ -51,11 +52,13 @@ const props = defineProps<{
items: PurchaseCartItem[]; items: PurchaseCartItem[];
photos?: MediaItem | null; photos?: MediaItem | null;
}; };
draftItems?: PurchaseCartItem[];
submitUrl: string; submitUrl: string;
method: 'post' | 'put'; method: 'post' | 'put';
submitLabel: string; submitLabel: string;
}>(); }>();
const isCreateMode = computed(() => props.method === 'post');
const search = ref(''); const search = ref('');
const cart = ref<PurchaseCartItem[]>([]); const cart = ref<PurchaseCartItem[]>([]);
const existingPhotoId = ref<number | null>(null); const existingPhotoId = ref<number | null>(null);
@ -99,6 +102,31 @@ watch(
{ immediate: true }, { 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 = { type CatalogPrice = {
id: number; id: number;
variant: string; variant: string;
@ -134,14 +162,36 @@ function lineSubtotal(item: PurchaseCartItem): number {
return Math.round(quantity * item.unit_price); 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( const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id, (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) { if (existing) {
const currentQty = Number(existing.quantity) || 0; existing.quantity = String(nextQty);
existing.quantity = String(currentQty + 1);
return; 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); cart.value.splice(index, 1);
} }
function adjustQuantity(index: number, delta: number) { async function adjustQuantity(index: number, delta: number) {
const item = cart.value[index]; const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta; const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) { 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; return;
} }
@ -174,6 +248,27 @@ function adjustQuantity(index: number, delta: number) {
item.quantity = String(nextQty); 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 { function buildFormData(): FormData {
const formData = new FormData(); const formData = new FormData();
@ -185,10 +280,12 @@ function buildFormData(): FormData {
formData.append('discount', parseRupiah(form.discount)); formData.append('discount', parseRupiah(form.discount));
formData.append('notes', form.notes); formData.append('notes', form.notes);
cart.value.forEach((item, index) => { if (props.method === 'put') {
formData.append(`items[${index}][raw_material_price_id]`, String(item.raw_material_price_id)); cart.value.forEach((item, index) => {
formData.append(`items[${index}][quantity]`, item.quantity); 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]; const removeMediaIds = [...form.remove_media_ids];
@ -353,7 +450,8 @@ function submit() {
<Minus class="size-3.5" /> <Minus class="size-3.5" />
</Button> </Button>
<Input v-model="item.quantity" type="number" min="0.0001" <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" <Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)"> class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" /> <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 OrderPosForm from '@/components/admin/manage/orders/OrderPosForm.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, OrderCatalogItem, SelectOption } from '@/types/order'; import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
defineProps<{ defineProps<{
customers: SelectOption[]; customers: SelectOption[];
catalog: OrderCatalogItem[]; catalog: OrderCatalogItem[];
channels: EnumOption[]; channels: EnumOption[];
storePriceTypes: EnumOption[]; storePriceTypes: EnumOption[];
draftItems: OrderCartItem[];
}>(); }>();
</script> </script>
@ -38,6 +39,7 @@ defineProps<{
:catalog="catalog" :catalog="catalog"
:channels="channels" :channels="channels"
:store-price-types="storePriceTypes" :store-price-types="storePriceTypes"
:draft-items="draftItems"
submit-url="/admin/manage/orders" submit-url="/admin/manage/orders"
method="post" method="post"
submit-label="Simpan" submit-label="Simpan"

View File

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

View File

@ -15,7 +15,9 @@
use App\Http\Controllers\Admin\Hr\LeaveRequestController; use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\CuttingController; use App\Http\Controllers\Admin\Manage\CuttingController;
use App\Http\Controllers\Admin\Manage\OrderController; 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\PurchaseController;
use App\Http\Controllers\Admin\Manage\PurchaseDraftItemController;
use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController; use App\Http\Controllers\Admin\Master\ProductController;
@ -168,6 +170,14 @@
->middleware('permission:'.Permission::PURCHASES_CREATE->value) ->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('create'); ->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']) Route::post('/', [PurchaseController::class, 'store'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value) ->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('store'); ->name('store');
@ -194,6 +204,18 @@
->middleware('permission:'.Permission::ORDERS_CREATE->value) ->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('create'); ->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']) Route::post('/', [OrderController::class, 'store'])
->middleware('permission:'.Permission::ORDERS_CREATE->value) ->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('store'); ->name('store');