feat: add functionality to create new raw materials and store them in the purchase draft, enhancing the purchasing process

This commit is contained in:
Yoga Pangestu 2026-07-09 14:54:23 +07:00
parent c78afb6477
commit a3954faab6
14 changed files with 1248 additions and 293 deletions

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest;
use App\Http\Requests\Admin\Manage\PurchaseNewRawMaterialRequest;
use App\Http\Requests\Admin\Manage\PurchaseNewVariantRequest;
use App\Models\RawMaterialPrice;
use App\Services\Manage\PurchaseService;
@ -30,6 +31,13 @@ public function storeNewVariant(PurchaseNewVariantRequest $request): JsonRespons
return response()->json(['price' => $price]);
}
public function storeNewRawMaterial(PurchaseNewRawMaterialRequest $request): JsonResponse
{
$item = $this->purchaseService->createRawMaterialAndDraft($request->validated(), $request->user());
return response()->json(['item' => $item]);
}
public function destroy(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse
{
$this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice);

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PurchaseNewRawMaterialRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
return $this->user()?->can(Permission::PURCHASES_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
'variant' => ['required', 'string', 'max:200'],
'price' => ['required', 'integer', 'gt:0'],
'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'photos' => ['required_without:s3_keys', 'array', 'min:1', 'max:5'],
'photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
's3_keys' => ['required_without:photos', 'array', 'min:1', 'max:5'],
's3_keys.*' => ['required', 'string'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'photos.required_without' => 'Foto varian wajib diisi.',
's3_keys.required_without' => 'Foto varian wajib diisi.',
'photos.min' => 'Foto varian wajib diisi.',
's3_keys.min' => 'Foto varian wajib diisi.',
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => 'nama bahan baku',
'unit' => 'satuan',
'variant' => 'nama varian',
'price' => 'harga',
'quantity' => 'jumlah',
...$this->photoUploadAttributes('foto varian'),
];
}
}

View File

@ -25,25 +25,24 @@ public function authorize(): bool
*/
public function rules(): array
{
$rules = [
return [
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],
...$this->photoRules(),
...$this->photoRules('photos', 1),
'items' => ['required', 'array', 'min:1'],
'items.*.raw_material_price_id' => ['nullable', 'integer'],
'items.*.name' => ['required', 'string', 'max:200'],
'items.*.unit' => ['required', 'string', Rule::in(['yard', 'meter', 'kilogram'])],
'items.*.variant' => ['required', 'string', 'max:200'],
'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'items.*.price' => ['required', 'integer', 'gt:0'],
'items.*.photos' => ['nullable', 'array', 'max:5'],
'items.*.photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
...$this->variantImageRules('items', 5),
];
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'),
];
$rules['items.*.quantity'] = ['required', 'numeric', 'decimal:0,4', 'gt:0'];
}
return $rules;
}
/**
@ -58,8 +57,13 @@ public function attributes(): array
'notes' => 'keterangan',
'items' => 'bahan baku',
'items.*.raw_material_price_id' => 'bahan baku',
'items.*.name' => 'nama bahan baku',
'items.*.unit' => 'satuan',
'items.*.variant' => 'nama varian',
'items.*.quantity' => 'jumlah',
...$this->photoUploadAttributes('bukti transaksi'),
'items.*.price' => 'harga',
...$this->photoUploadAttributes('bukti transaksi', 'photos'),
...$this->variantImageAttributes('items', 'foto varian'),
];
}
}

View File

@ -248,6 +248,113 @@ public function createVariantAndDraft(array $validated, User $user): array
];
}
public function createRawMaterialAndDraft(array $validated, User $user): array
{
$data = $this->runInTransaction(
function () use ($validated, $user): array {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$rawMaterial = RawMaterial::withTrashed()
->where('name', $validated['name'])
->where('unit', $validated['unit'])
->first();
if ($rawMaterial) {
if ($rawMaterial->trashed()) {
$rawMaterial->restore();
}
if ($isOwner && !$rawMaterial->is_active) {
$rawMaterial->update(['is_active' => true]);
}
} else {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => $isOwner,
]);
}
$price = RawMaterialPrice::withTrashed()
->where('raw_material_id', $rawMaterial->id)
->where('variant', $validated['variant'])
->first();
if ($price) {
if ($price->trashed()) {
$price->restore();
}
$price->update([
'price' => (int) $validated['price'],
]);
} else {
$price = RawMaterialPrice::create([
'raw_material_id' => $rawMaterial->id,
'variant' => $validated['variant'],
'price' => (int) $validated['price'],
'stock' => 0.0,
]);
}
$quantity = (float) $validated['quantity'];
$unitPrice = (int) $price->price;
$subtotal = (int) round($quantity * $unitPrice);
$existingItem = PurchaseItem::query()
->where('user_id', $user->id)
->where('raw_material_price_id', $price->id)
->whereNull('purchase_id')
->first();
if ($existingItem) {
$newQty = $existingItem->quantity + $quantity;
$existingItem->update([
'quantity' => $newQty,
'unit_price' => $unitPrice,
'subtotal' => (int) round($newQty * $unitPrice),
]);
$item = $existingItem;
} else {
$item = PurchaseItem::create([
'user_id' => $user->id,
'raw_material_price_id' => $price->id,
'purchase_id' => null,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
]);
}
return [$price, $item];
},
'Gagal membuat bahan baku baru dan menambahkan ke keranjang'
);
[$price, $item] = $data;
if ($price->media()->count() === 0 && (! empty($validated['photos']) || ! empty($validated['s3_keys']))) {
$this->mediaService->syncCollection(
$price,
'images',
$validated['photos'] ?? null,
null,
5,
required: false,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}
$item->load([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
]);
$result = $this->presentDraftItem($item);
$this->breakItemCircularReference($item);
return $result;
}
public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void
{
PurchaseItem::query()
@ -263,18 +370,9 @@ public function create(array $validated, User $user): Purchase
$purchase = $this->runInTransaction(
function () use ($validated, $user, $isOwner): Purchase {
$resolvedItems = $this->processRequestItems($validated['items'] ?? [], $isOwner);
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
->get();
if ($draftItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => 'Tambahkan minimal satu bahan baku ke keranjang.',
]);
}
$subtotal = $draftItems->sum('subtotal');
$subtotal = array_sum(array_column($resolvedItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
@ -289,9 +387,12 @@ function () use ($validated, $user, $isOwner): Purchase {
'notes' => $validated['notes'] ?? null,
]);
foreach ($draftItems as $item) {
$item->update([
'purchase_id' => $purchase->id,
foreach ($resolvedItems as $itemData) {
$purchase->items()->create([
'raw_material_price_id' => $itemData['raw_material_price_id'],
'quantity' => $itemData['quantity'],
'unit_price' => $itemData['unit_price'],
'subtotal' => $itemData['subtotal'],
]);
}
@ -345,7 +446,7 @@ public function update(Purchase $purchase, array $validated, User $user): void
$this->runInTransaction(
function () use ($purchase, $validated, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->buildPayloadFromValidated($validated);
$payload = $this->buildPayloadFromValidated($validated, $isOwner);
$this->applyPayloadToPurchase($purchase, $payload);
if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) {
@ -360,7 +461,7 @@ function () use ($purchase, $validated, $user, $isOwner): void {
'submitted_by_id' => $user->id,
'payload' => [
'old' => $this->snapshotPurchase($purchase),
'new' => $this->buildPayloadFromValidated($validated),
'new' => $this->buildPayloadFromValidated($validated, $isOwner),
],
]);
@ -369,7 +470,7 @@ function () use ($purchase, $validated, $user, $isOwner): void {
}
}
},
'Gagal memperbarui pembelian',
'Gagal memperbarui belanja',
);
if (! $isOwner) {
@ -709,9 +810,9 @@ private function snapshotPurchase(Purchase $purchase): array
];
}
private function buildPayloadFromValidated(array $validated): array
private function buildPayloadFromValidated(array $validated, bool $isOwner): array
{
$lineItems = $this->enrichLineItems($this->buildLineItems($validated['items']));
$lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'], $isOwner));
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
@ -731,6 +832,83 @@ private function buildPayloadFromValidated(array $validated): array
];
}
private function processRequestItems(array $items, bool $isOwner): array
{
$resolvedItems = [];
foreach ($items as $index => $itemData) {
// Resolve RawMaterial
$rawMaterial = RawMaterial::withTrashed()
->where('name', $itemData['name'])
->where('unit', $itemData['unit'])
->first();
if ($rawMaterial) {
if ($rawMaterial->trashed()) {
$rawMaterial->restore();
}
if ($isOwner && !$rawMaterial->is_active) {
$rawMaterial->update(['is_active' => true]);
}
} else {
$rawMaterial = RawMaterial::create([
'name' => $itemData['name'],
'unit' => $itemData['unit'],
'is_active' => $isOwner,
]);
}
// Resolve RawMaterialPrice (variant)
$price = RawMaterialPrice::withTrashed()
->where('raw_material_id', $rawMaterial->id)
->where('variant', $itemData['variant'])
->first();
if ($price) {
if ($price->trashed()) {
$price->restore();
}
$price->update([
'price' => (int) $itemData['price'],
]);
} else {
$price = RawMaterialPrice::create([
'raw_material_id' => $rawMaterial->id,
'variant' => $itemData['variant'],
'price' => (int) $itemData['price'],
'stock' => 0.0,
]);
}
// Sync variant photo if provided
if (!empty($itemData['photos']) || !empty($itemData['s3_keys']) || !empty($itemData['remove_media_ids'])) {
$this->mediaService->syncCollection(
$price,
'images',
$itemData['photos'] ?? null,
$itemData['remove_media_ids'] ?? null,
5,
required: false,
errorKey: "items.{$index}.photos",
s3Keys: $itemData['s3_keys'] ?? null,
);
}
$quantity = (float) $itemData['quantity'];
$unitPrice = (int) $price->price;
$lineSubtotal = (int) round($quantity * $unitPrice);
$resolvedItems[] = [
'raw_material_price_id' => $price->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $lineSubtotal,
];
}
return $resolvedItems;
}
private function enrichLineItems(array $lineItems): array
{
$prices = RawMaterialPrice::query()

View File

@ -52,7 +52,7 @@ const emit = defineEmits<{
<Button
type="button"
:disabled="loading"
:class="cn(destructive && 'bg-destructive text-white hover:bg-destructive/90')"
:variant="destructive ? 'destructive' : 'default'"
@click="emit('confirm')"
>
{{ loading ? 'Memproses...' : confirmLabel }}

View File

@ -184,12 +184,8 @@ async function submit() {
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Tambah Bahan Baku Baru</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
<form id="quick-create-raw-material-form" @submit.prevent="submit" class="space-y-6">
<form id="quick-create-raw-material-form" @submit.prevent="submit">
<RawMaterialInfoSection :form="form" :units="units" method="post"
:select-portal-target="selectPortalTarget" />

View File

@ -25,11 +25,11 @@ const initialData = computed(() => ({
items: props.purchase.items.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
raw_material_name: item.raw_material_name,
variant: item.variant_name,
variant: item.variant ?? item.variant_name,
unit_abbreviation: item.unit_abbreviation,
quantity: item.quantity_input,
unit_price: item.unit_price,
thumb_url: item.raw_material_price?.images?.[0]?.thumb_url ?? null,
images: item.raw_material_price?.images ?? [],
})),
photos: props.purchase.photos ?? null,
}));

View File

@ -12,6 +12,8 @@ import {
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { Trash2 } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { formatRupiah } from '@/lib/rupiah';
import type { PurchaseCartItem } from '@/types/purchase';
@ -65,13 +67,15 @@ const hasItems = computed(() => props.cart.length > 0);
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ item.variant }}</p>
</div>
<button
<Button
type="button"
class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="emit('remove', index)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
<Trash2 class="size-3.5" />
</Button>
</div>
<label class="mt-2 block text-xs text-muted-foreground">

View File

@ -1,139 +1,178 @@
<script setup lang="ts">
import { Minus, Plus, Search, PlusCircle } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Plus } from '@lucide/vue';
import { ref } from 'vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { PurchaseCartItem, PurchaseCatalogItem } from '@/types/purchase';
import type { PurchaseCatalogPrice } from './usePurchasePosCart';
defineProps<{
filteredCatalog: PurchaseCatalogItem[];
getCartItem: (priceId: number) => PurchaseCartItem | undefined;
}>();
const search = defineModel<string>('search', { required: true });
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { parseRupiah } from '@/lib/rupiah';
import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
const emit = defineEmits<{
'add-to-cart': [rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice];
'decrease-qty': [priceId: number];
'add-variant': [rawMaterial: PurchaseCatalogItem];
'add-new': [formData: FormData, callback: (success: boolean) => void];
}>();
const form = ref({
name: '',
unit: 'yard',
variant: '',
price: '',
quantity: '',
});
const photoState = ref<MediaUploadState>(createMediaUploadState());
const isSubmitting = ref(false);
const errors = ref<Record<string, string>>({});
function submitNewRawMaterial() {
errors.value = {};
if (!form.value.name.trim()) {
errors.value.name = 'Nama bahan baku wajib diisi.';
}
if (!form.value.unit) {
errors.value.unit = 'Satuan wajib dipilih.';
}
if (!form.value.variant.trim()) {
errors.value.variant = 'Varian wajib diisi.';
}
const rawPrice = parseRupiah(form.value.price);
if (!rawPrice || Number(rawPrice) <= 0) {
errors.value.price = 'Harga beli harus lebih besar dari 0.';
}
const qty = Number(form.value.quantity);
if (isNaN(qty) || qty <= 0) {
errors.value.quantity = 'Jumlah harus lebih besar dari 0.';
}
if (photoState.value.newFiles.length === 0 && photoState.value.newFileS3Keys.length === 0) {
errors.value.photos = 'Foto varian wajib diisi.';
}
if (Object.keys(errors.value).length > 0) {
return;
}
isSubmitting.value = true;
const formData = new FormData();
formData.append('name', form.value.name.trim());
formData.append('unit', form.value.unit);
formData.append('variant', form.value.variant.trim());
formData.append('price', String(rawPrice));
formData.append('quantity', String(qty));
if (photoState.value.newFiles.length > 0) {
photoState.value.newFiles.forEach((file) => {
formData.append('photos[]', file);
});
}
if (photoState.value.newFileS3Keys.length > 0) {
photoState.value.newFileS3Keys.forEach((key) => {
formData.append('s3_keys[]', key);
});
}
emit('add-new', formData, (success) => {
isSubmitting.value = false;
if (success) {
form.value.name = '';
form.value.unit = 'yard';
form.value.variant = '';
form.value.price = '';
form.value.quantity = '';
photoState.value = createMediaUploadState();
}
});
}
</script>
<template>
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div class="space-y-6">
<Card class="min-w-0 shadow-sm border border-muted-foreground/10">
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="new-name" required>Nama Bahan Baku</FieldLabel>
<Input id="new-name" v-model="form.name" placeholder="Masukkan nama bahan baku" />
<FieldError :errors="errors.name ? [errors.name] : []" />
</Field>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<Field>
<FieldLabel for="new-unit" required>Satuan</FieldLabel>
<Select v-model="form.unit">
<SelectTrigger id="new-unit" class="w-full">
<SelectValue placeholder="Pilih satuan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="yard">Yard</SelectItem>
<SelectItem value="meter">Meter</SelectItem>
<SelectItem value="kilogram">Kilogram</SelectItem>
</SelectContent>
</Select>
<FieldError :errors="errors.unit ? [errors.unit] : []" />
</Field>
</FieldSet>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel for="new-variant" required>Nama Varian</FieldLabel>
<Input id="new-variant" v-model="form.variant"
placeholder="Contoh: Standard, Hitam, Merah" />
<FieldError :errors="errors.variant ? [errors.variant] : []" />
</Field>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard
v-for="rawMaterial in filteredCatalog"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)"
>
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<Field>
<FieldLabel for="new-quantity" required>Jumlah Beli</FieldLabel>
<Input id="new-quantity" type="number" step="any" v-model="form.quantity" placeholder="0" />
<FieldError :errors="errors.quantity ? [errors.quantity] : []" />
</Field>
<div class="px-3 pt-1 pb-1.5">
<Button
type="button"
variant="ghost"
size="sm"
class="w-full text-xs text-muted-foreground hover:text-primary"
@click.stop="emit('add-variant', rawMaterial)"
>
<PlusCircle class="size-3.5" />
Tambah Varian
</Button>
</div>
<Field>
<FieldLabel for="new-price" required>Harga Beli</FieldLabel>
<RupiahInput id="new-price" v-model="form.price" placeholder="0" />
<FieldDescription>Harga adalah harga per satuan bahan baku.</FieldDescription>
<FieldError :errors="errors.price ? [errors.price] : []" />
</Field>
</FieldSet>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
'cursor-pointer hover:bg-muted/30',
getCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : '',
]"
@click="!getCartItem(price.id) && emit('add-to-cart', rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
{{ price.price_formatted }}
</p>
</div>
<div v-if="getCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-qty', price.id)"
>
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getCartItem(price.id)!.quantity }}
</span>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-to-cart', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
<Button
v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
@click.stop="emit('add-to-cart', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
<MediaDropzone id="new-variant-photos" v-model="photoState" label="Foto Varian" required
:max-files="5" :errors="errors.photos ? [errors.photos] : []" />
</FieldGroup>
</CardContent>
</Card>
<div class="flex justify-end">
<Button type="button" class="flex items-center justify-center"
:disabled="isSubmitting || photoState.pendingUploads > 0" @click="submitNewRawMaterial">
<Plus class="size-4" />
Tambah
</Button>
</div>
</div>
</template>

View File

@ -1,25 +1,31 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ShoppingCart } from '@lucide/vue';
import { Plus, ShoppingCart } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
FieldError,
FieldGroup,
FieldSet,
} from '@/components/ui/field';
import { useVariantList } from '@/composables/useVariantList';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import RawMaterialInfoSection from '@/pages/admin/master/raw-materials/form/RawMaterialInfoSection.vue';
import RawMaterialSharedPriceSection from '@/pages/admin/master/raw-materials/form/RawMaterialSharedPriceSection.vue';
import RawMaterialVariantSection from '@/pages/admin/master/raw-materials/form/RawMaterialVariantSection.vue';
import { appendMediaToFormData, appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
import type { RawMaterialPrice } from '@/types/raw-material';
import PurchasePosAddVariantDialog from './PurchasePosAddVariantDialog.vue';
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
import PurchasePosCartDetailDialog from './PurchasePosCartDetailDialog.vue';
import PurchasePosCartSummaryItems from './PurchasePosCartSummaryItems.vue';
import PurchasePosCatalogPanel from './PurchasePosCatalogPanel.vue';
import PurchasePosCheckoutSection from './PurchasePosCheckoutSection.vue';
import PurchasePosMetadataFields from './PurchasePosMetadataFields.vue';
import { usePurchasePosCart } from './usePurchasePosCart';
const props = defineProps<{
suppliers: SelectOption[];
@ -39,72 +45,476 @@ const props = defineProps<{
}>();
const isCreateMode = computed(() => props.method === 'post');
const cartDetailOpen = ref(false);
const addVariantOpen = ref(false);
const addVariantRawMaterial = ref<PurchaseCatalogItem | null>(null);
const photoState = ref<MediaUploadState>(createMediaUploadState());
const savingStates = ref<Record<string, boolean>>({});
const cartDetailOpen = ref(false);
// Confirmation Dialog state
const showDeleteConfirm = ref(false);
const priceToDelete = ref<string | null>(null);
const form = useForm({
supplier_id: '',
discount: '',
shipping_cost: '',
notes: '',
name: '',
unit: '',
});
const units = [
{ value: 'yard', label: 'Yard' },
{ value: 'meter', label: 'Meter' },
{ value: 'kilogram', label: 'Kilogram' },
];
function createClientId(): string {
return `price-${crypto.randomUUID()}`;
}
const {
search,
cart,
filteredCatalog,
subtotal,
setCart,
loadDraftItems,
getCartItem,
lineSubtotal,
addToCart,
removeFromCart,
adjustQuantity,
syncCartItemQuantity,
setCartItemQuantity,
decreasePriceQty,
} = usePurchasePosCart({
catalog: () => props.catalog,
isCreateMode: () => isCreateMode.value,
items: prices,
removeItem: removePrice,
setField: setPriceField,
appendToFormData,
itemErrors: priceErrorsList,
} = useVariantList<RawMaterialPriceFormItem>(
'prices',
() => ({
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}),
() => {
if (props.initialData && props.initialData.items.length > 0) {
return props.initialData.items.map((item) => ({
client_id: createClientId(),
id: item.raw_material_price_id,
variant: item.variant ?? '',
price: String(item.unit_price ?? ''),
stock: String(item.quantity ?? '0'),
media: createMediaUploadState(item.images ?? []),
}));
}
if (props.draftItems && props.draftItems.length > 0) {
return props.draftItems.map((item) => ({
client_id: createClientId(),
id: item.raw_material_price_id,
variant: item.variant ?? '',
price: String(item.unit_price ?? ''),
stock: String(item.quantity ?? '0'),
media: createMediaUploadState(item.images ?? []),
}));
}
return [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
}
);
const manualUseSamePrice = ref<boolean | null>(null);
const useSamePrice = computed(() => {
if (manualUseSamePrice.value !== null) {
return manualUseSamePrice.value;
}
return prices.value.length <= 1 || allPricesHaveSameValue();
});
function allPricesHaveSameValue(): boolean {
if (prices.value.length <= 1) {
return true;
}
const first = prices.value[0].price.trim();
return prices.value.every((item) => item.price.trim() === first);
}
function toggleUseSamePrice(checked: boolean) {
manualUseSamePrice.value = checked;
if (!checked || !prices.value[0]) {
return;
}
const sourcePrice = prices.value[0].price;
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
}
function applyPriceToAllVariants(sourceClientId: string) {
const source = prices.value.find((price) => price.client_id === sourceClientId);
if (!source) {
return;
}
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
}
function addPrice() {
const newPrice: RawMaterialPriceFormItem = {
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
};
if (useSamePrice.value && prices.value[0]) {
newPrice.price = prices.value[0].price;
}
prices.value = [...prices.value, newPrice];
}
function setPriceValue(clientId: string, value: string) {
setPriceField(clientId, 'price', value);
}
function setSharedPrice(value: string) {
prices.value = prices.value.map((price) => ({ ...price, price: value }));
}
// Reactively map the prices array to the CartItem structure expected by components
const cart = computed(() => {
return prices.value.map((price) => {
let unitAbbr = 'yd';
const u = form.unit?.toLowerCase();
if (u === 'meter') unitAbbr = 'm';
else if (u === 'kilogram') unitAbbr = 'kg';
return {
raw_material_price_id: price.id || 0,
raw_material_name: form.name || 'Bahan Baku',
variant: price.variant || 'Varian Baru',
unit_abbreviation: unitAbbr,
quantity: price.stock,
unit_price: Number(parseRupiah(price.price)) || 0,
images: [], // not used in cart detail displays
};
});
});
function confirmRemovePrice(clientId: string) {
priceToDelete.value = clientId;
showDeleteConfirm.value = true;
}
async function handleRemovePrice() {
if (!priceToDelete.value) return;
const clientId = priceToDelete.value;
const idx = prices.value.findIndex((p) => p.client_id === clientId);
if (idx !== -1) {
const price = prices.value[idx];
if (price.id) {
try {
await apiFetch(`/admin/manage/purchases/draft-items/${price.id}`, {
method: 'DELETE',
});
} catch (error) {
console.error(error);
}
}
removePrice(clientId);
}
priceToDelete.value = null;
showDeleteConfirm.value = false;
}
function removeFromCart(index: number) {
const price = prices.value[index];
if (price) {
confirmRemovePrice(price.client_id);
}
}
function adjustQuantity(index: number, delta: number) {
const price = prices.value[index];
if (price) {
const nextQty = (Number(price.stock) || 0) + delta;
if (nextQty <= 0) {
confirmRemovePrice(price.client_id);
} else {
price.stock = String(nextQty);
debouncedSave(price);
}
}
}
function syncCartItemQuantity(index: number) {
const price = prices.value[index];
if (price) {
debouncedSave(price);
}
}
function setCartItemQuantity(index: number, value: string) {
const price = prices.value[index];
if (price) {
price.stock = value;
debouncedSave(price);
}
}
function lineSubtotal(item: any): number {
const qty = Number(item.quantity) || 0;
return Math.round(qty * item.unit_price);
}
// Debounce helper to prevent spamming database saves while typing
function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args);
}, delay);
};
}
// Auto-save variant to DB logic
async function savePriceToDb(price: typeof prices.value[0]) {
if (
!form.name.trim() ||
!form.unit ||
!price.variant.trim() ||
!price.stock ||
Number(price.stock) <= 0 ||
!price.price ||
Number(parseRupiah(price.price)) <= 0
) {
return;
}
if (savingStates.value[price.client_id]) return;
savingStates.value[price.client_id] = true;
try {
if (!price.id) {
const formData = new FormData();
formData.append('name', form.name.trim());
formData.append('unit', form.unit);
formData.append('variant', price.variant.trim());
formData.append('price', parseRupiah(price.price));
formData.append('quantity', price.stock);
if (price.media.newFiles.length > 0) {
price.media.newFiles.forEach((file) => {
formData.append('photos[]', file);
});
}
const response = await apiFetch<{ item: any }>(
'/admin/manage/purchases/draft-items/new-raw-material',
{
method: 'POST',
body: formData,
}
);
price.id = response.item.raw_material_price_id;
toast.success(`Varian "${price.variant}" berhasil disimpan.`);
} else {
await apiFetch(
'/admin/manage/purchases/draft-items',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
raw_material_price_id: price.id,
quantity: Number(price.stock) || 0,
}),
}
);
}
} catch (error) {
console.error(error);
} finally {
savingStates.value[price.client_id] = false;
}
}
const debouncedSave = debounce(async (price: typeof prices.value[0]) => {
await savePriceToDb(price);
}, 600);
// Variant master form event interceptors
function handleUpdateVariant(clientId: string, value: string) {
setPriceField(clientId, 'variant', value);
const price = prices.value.find((p) => p.client_id === clientId);
if (price) debouncedSave(price);
}
function handleUpdateStock(clientId: string, value: string) {
setPriceField(clientId, 'stock', value);
const price = prices.value.find((p) => p.client_id === clientId);
if (price) debouncedSave(price);
}
function handleUpdatePrice(clientId: string, value: string) {
setPriceValue(clientId, value);
const price = prices.value.find((p) => p.client_id === clientId);
if (price) debouncedSave(price);
}
function handleApplyPriceToAll(clientId: string) {
applyPriceToAllVariants(clientId);
prices.value.forEach((price) => debouncedSave(price));
}
function handleToggleUseSamePrice(checked: boolean) {
toggleUseSamePrice(checked);
prices.value.forEach((price) => debouncedSave(price));
}
async function handleSetSharedPrice(value: string) {
setSharedPrice(value);
prices.value.forEach((price) => debouncedSave(price));
}
// Autocomplete from props.catalog
const catalogMaterialNames = computed(() => {
return [...new Set(props.catalog.map((c) => c.name))];
});
function onMaterialNameInput() {
const matched = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
);
if (matched) {
form.unit = matched.unit;
}
// Save any newly valid variants
prices.value.forEach((price) => debouncedSave(price));
}
function getVariantsForMaterial() {
const matched = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
);
return matched ? matched.prices.map((p) => p.variant) : [];
}
function onVariantInput(price: typeof prices.value[0]) {
const matchedMaterial = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
);
if (matchedMaterial) {
const matchedPrice = matchedMaterial.prices.find(
(p) => p.variant.toLowerCase() === price.variant.trim().toLowerCase()
);
if (matchedPrice) {
price.id = matchedPrice.id;
price.price = String(matchedPrice.price);
debouncedSave(price);
}
}
}
// Map frontend validation errors from RawMaterialForm style to items.* backend array style
function priceErrors(form: any, clientId: string, field: string): string[] {
const index = prices.value.findIndex((p) => p.client_id === clientId);
if (index === -1) return [];
let mappedField = field;
if (field === 'stock') mappedField = 'quantity';
else if (field === 'images') mappedField = 'photos';
return formErrors(form, `items.${index}.${mappedField}`);
}
// Calculations
const subtotal = computed(() => {
return prices.value.reduce((sum, price) => {
const qty = Number(price.stock) || 0;
const pr = Number(parseRupiah(price.price)) || 0;
return sum + Math.round(qty * pr);
}, 0);
});
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const shippingAmount = computed(() => Number(parseRupiah(form.shipping_cost)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value + shippingAmount.value, 0));
function populateForm() {
if (!props.initialData) {
return;
}
const isUploading = computed(() =>
prices.value.some((p) => p.media.pendingUploads > 0)
);
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.shipping_cost = props.initialData.shipping_cost ?? '0';
form.notes = props.initialData.notes;
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : [],
);
setCart(props.initialData.items);
function populateForm() {
manualUseSamePrice.value = null;
if (props.initialData) {
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.shipping_cost = props.initialData.shipping_cost ?? '0';
form.notes = props.initialData.notes;
photoState.value = createMediaUploadState(
props.initialData.photos ? [props.initialData.photos] : []
);
if (props.initialData.items.length > 0) {
const firstItem = props.initialData.items[0];
form.name = firstItem.raw_material_name || '';
let unitVal = 'yard';
const ua = firstItem.unit_abbreviation?.toLowerCase();
if (ua === 'm' || ua === 'meter') unitVal = 'meter';
else if (ua === 'kg' || ua === 'kilogram') unitVal = 'kilogram';
else if (ua === 'yd' || ua === 'yard') unitVal = 'yard';
form.unit = unitVal;
}
prices.value = props.initialData.items.map((item) => ({
client_id: createClientId(),
id: item.raw_material_price_id,
variant: item.variant ?? item.variant_name ?? '',
price: String(item.unit_price ?? ''),
stock: String(item.quantity ?? '0'),
media: createMediaUploadState(item.images ?? []),
}));
} else if (props.draftItems && props.draftItems.length > 0) {
const firstItem = props.draftItems[0];
form.name = firstItem.raw_material_name || '';
let unitVal = 'yard';
const ua = firstItem.unit_abbreviation?.toLowerCase();
if (ua === 'm' || ua === 'meter') unitVal = 'meter';
else if (ua === 'kg' || ua === 'kilogram') unitVal = 'kilogram';
else if (ua === 'yd' || ua === 'yard') unitVal = 'yard';
form.unit = unitVal;
prices.value = props.draftItems.map((item) => ({
client_id: createClientId(),
id: item.raw_material_price_id,
variant: item.variant ?? item.variant_name ?? '',
price: String(item.unit_price ?? ''),
stock: String(item.quantity ?? '0'),
media: createMediaUploadState(item.images ?? []),
}));
} else {
form.name = '';
form.unit = 'yard';
prices.value = [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
}
}
watch(() => props.initialData, populateForm, { immediate: true });
loadDraftItems(props.draftItems ?? []);
function openAddVariant(rawMaterial: PurchaseCatalogItem) {
addVariantRawMaterial.value = rawMaterial;
addVariantOpen.value = true;
}
function handleVariantAdded(rawMaterialId: number, newPrice: RawMaterialPrice) {
const rawMaterial = props.catalog.find((rm) => rm.id === rawMaterialId);
if (rawMaterial) {
rawMaterial.prices.push(newPrice);
}
}
function buildFormData(): FormData {
const formData = new FormData();
@ -118,12 +528,17 @@ function buildFormData(): FormData {
formData.append('shipping_cost', parseRupiah(form.shipping_cost));
formData.append('notes', form.notes);
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);
});
}
prices.value.forEach((price, index) => {
if (price.id) {
formData.append(`items[${index}][raw_material_price_id]`, String(price.id));
}
formData.append(`items[${index}][name]`, form.name.trim());
formData.append(`items[${index}][unit]`, form.unit);
formData.append(`items[${index}][variant]`, price.variant.trim());
formData.append(`items[${index}][quantity]`, price.stock);
formData.append(`items[${index}][price]`, parseRupiah(price.price));
appendMediaToFormData(formData, `items[${index}]`, price.media);
});
appendRootPhotosToFormData(formData, photoState.value);
@ -131,15 +546,43 @@ function buildFormData(): FormData {
}
function submit() {
if (cart.value.length === 0) {
toast.error('Tambahkan minimal satu bahan baku ke keranjang.');
if (!form.name.trim()) {
toast.error('Nama bahan baku wajib diisi.');
return;
}
if (!form.unit) {
toast.error('Satuan bahan baku wajib dipilih.');
return;
}
if (prices.value.length === 0) {
toast.error('Tambahkan minimal satu varian.');
return;
}
for (let i = 0; i < prices.value.length; i++) {
const p = prices.value[i];
if (!p.variant.trim()) {
toast.error(`Nama varian pada baris ke-${i + 1} wajib diisi.`);
return;
}
if (!p.stock || Number(p.stock) <= 0) {
toast.error(`Jumlah beli pada baris ke-${i + 1} harus lebih besar dari 0.`);
return;
}
const parsedPrice = Number(parseRupiah(p.price)) || 0;
if (parsedPrice <= 0) {
toast.error(`Harga beli pada baris ke-${i + 1} harus lebih besar dari 0.`);
return;
}
const imageCount = p.media.existing.length - p.media.removeIds.length + p.media.newFiles.length;
if (imageCount <= 0) {
toast.error(`Foto varian pada baris ke-${i + 1} wajib diunggah.`);
return;
}
}
if (!form.supplier_id) {
toast.error('Pilih supplier terlebih dahulu.');
return;
}
@ -150,6 +593,13 @@ function submit() {
onError: (errors: Record<string, string>) => {
if (errors.system) {
toast.error(errors.system);
} else {
const firstError = Object.values(errors)[0];
if (firstError) {
toast.error(firstError);
} else {
toast.error('Perbaiki kesalahan pada form.');
}
}
},
});
@ -157,16 +607,37 @@ function submit() {
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<PurchasePosCatalogPanel
v-model:search="search"
:filtered-catalog="filteredCatalog"
:get-cart-item="getCartItem"
@add-to-cart="addToCart"
@decrease-qty="decreasePriceQty"
@add-variant="openAddVariant"
/>
<datalist id="catalog-names">
<option v-for="name in catalogMaterialNames" :key="name" :value="name" />
</datalist>
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
<div class="space-y-6">
<RawMaterialInfoSection :form="form" :units="units" method="post" />
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
@toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" />
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
@update:variant="handleUpdateVariant(price.client_id, $event)"
@update:stock="handleUpdateStock(price.client_id, $event)"
@update:price="handleUpdatePrice(price.client_id, $event)" />
<FieldError :errors="formErrors(form, 'items')" />
<div class="flex justify-start">
<Button type="button" variant="outline" class="flex items-center gap-2" @click="addPrice">
<Plus class="size-4" />
Tambah Varian
</Button>
</div>
</div>
<!-- Right Column: Summary Panel (Ringkasan Belanja) -->
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between gap-2 text-base">
@ -188,8 +659,10 @@ function submit() {
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<!-- Supplier metadata input fields -->
<PurchasePosMetadataFields :form="form" :suppliers="suppliers" />
<!-- List of selected items in the summary card (original PurchasePosCartSummaryItems) -->
<PurchasePosCartSummaryItems
:cart="cart"
:line-subtotal="lineSubtotal"
@ -198,12 +671,13 @@ function submit() {
@sync-quantity="syncCartItemQuantity"
/>
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->
<PurchasePosCheckoutSection
v-model:photo-state="photoState"
:form="form"
:subtotal="subtotal"
:total="total"
:cart-empty="cart.length === 0"
:cart-empty="prices.length === 0 || !form.name.trim() || isUploading"
:submit-label="submitLabel"
/>
</FieldSet>
@ -213,12 +687,9 @@ function submit() {
</Card>
</div>
<PurchasePosAddVariantDialog
v-model:open="addVariantOpen"
:raw-material="addVariantRawMaterial"
@variant-added="handleVariantAdded"
/>
<!-- Original Cart detail dialog -->
<PurchasePosCartDetailDialog
v-model:open="cartDetailOpen"
:cart="cart"
@ -229,6 +700,7 @@ function submit() {
@set-quantity="setCartItemQuantity"
/>
<!-- Original Floating Cart button on mobile -->
<button
v-if="cart.length > 0"
type="button"
@ -240,4 +712,15 @@ function submit() {
{{ cart.length > 99 ? '99+' : cart.length }}
</span>
</button>
<!-- Confirm deletion dialog (same as in RawMaterialForm.vue) -->
<ConfirmDialog
v-model:open="showDeleteConfirm"
title="Hapus Varian?"
description="Apakah Anda yakin ingin menghapus varian ini? Tindakan ini tidak dapat dibatalkan."
confirm-label="Hapus"
cancel-label="Batal"
destructive
@confirm="handleRemovePrice"
/>
</template>

View File

@ -214,6 +214,31 @@ export function usePurchasePosCart(options: {
}
}
async function addNewRawMaterialToCart(formData: FormData, callback?: (success: boolean) => void) {
try {
const { item } = await apiFetch<{ item: PurchaseCartItem }>(
'/admin/manage/purchases/draft-items/new-raw-material',
{
method: 'POST',
body: formData,
}
);
upsertCartItem(item);
toast.success(`Bahan baku "${item.raw_material_name}" berhasil ditambahkan ke keranjang.`);
if (callback) {
callback(true);
}
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menambahkan bahan baku baru.');
if (callback) {
callback(false);
}
return false;
}
}
return {
search,
cart,
@ -230,5 +255,6 @@ export function usePurchasePosCart(options: {
setCartItemQuantity,
decreasePriceQty,
upsertCartItem,
addNewRawMaterialToCart,
};
}

View File

@ -41,24 +41,13 @@ const emit = defineEmits<{
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<div class="flex items-center gap-2">
<Button
v-if="totalPrices > 1 && !useSamePrice"
type="button"
variant="outline"
size="sm"
@click="emit('apply-price-to-all')"
>
<Button v-if="totalPrices > 1 && !useSamePrice" type="button" variant="outline" size="sm"
@click="emit('apply-price-to-all')">
<Copy class="size-4" />
Terapkan Harga ke Semua
</Button>
<Button
v-if="totalPrices > 1"
type="button"
variant="outline"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="emit('remove')"
>
<Button v-if="totalPrices > 1" type="button" variant="outline" size="icon"
class="text-destructive hover:text-destructive size-8" @click="emit('remove')">
<Trash2 class="size-4" />
</Button>
</div>
@ -70,51 +59,32 @@ const emit = defineEmits<{
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input
:id="`variant_${price.client_id}`"
:model-value="price.variant"
type="text"
placeholder="Masukkan nama varian"
:maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:variant', String($event))"
/>
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:variant', String($event))" />
<FieldError :errors="priceErrors(price.client_id, 'variant')" />
</Field>
<Field>
<FieldLabel :for="`stock_${price.client_id}`" required>
Stok
</FieldLabel>
<DecimalInput
:id="`stock_${price.client_id}`"
:model-value="price.stock"
@update:model-value="emit('update:stock', String($event))"
/>
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
@update:model-value="emit('update:stock', String($event))" />
<FieldError :errors="priceErrors(price.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`price_${price.client_id}`" required>
Harga
</FieldLabel>
<RupiahInput
:id="`price_${price.client_id}`"
:model-value="price.price"
@update:model-value="emit('update:price', $event)"
/>
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
@update:model-value="emit('update:price', $event)" />
<FieldDescription>Harga adalah harga per satuan bahan baku.</FieldDescription>
<FieldError :errors="priceErrors(price.client_id, 'price')" />
</Field>
</FieldSet>
<div class="mt-4">
<MediaDropzone
:id="`price_images_${price.client_id}`"
v-model="price.media"
label="Foto Varian"
:max-files="5"
required
:errors="priceErrors(price.client_id, 'images')"
/>
</div>
<MediaDropzone :id="`price_images_${price.client_id}`" v-model="price.media" label="Foto Varian"
:max-files="5" required :errors="priceErrors(price.client_id, 'images')" />
</FieldGroup>
</CardContent>
</Card>

View File

@ -264,6 +264,10 @@
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('draft_items.store_new_variant');
Route::post('draft-items/new-raw-material', [PurchaseDraftItemController::class, 'storeNewRawMaterial'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('draft_items.store_new_raw_material');
Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy'])
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
->name('draft_items.destroy');

View File

@ -61,6 +61,23 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
return $verificationRequest->fresh();
}
function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): array
{
$unit = $price->rawMaterial->unit;
$unitVal = $unit instanceof \BackedEnum ? $unit->value : $unit;
return [
[
'raw_material_price_id' => $price->id,
'name' => $price->rawMaterial->name,
'unit' => $unitVal,
'variant' => $price->variant,
'quantity' => $quantity,
'price' => $price->price,
]
];
}
describe('Purchase Owner Verification', function () {
test('create purchase submits verification without changing stock', function () {
$user = createPurchaseUserWithPermission(
@ -85,6 +102,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'discount' => 0,
'shipping_cost' => 0,
'notes' => 'Belanja test',
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -124,6 +142,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -156,6 +175,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -170,9 +190,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'discount' => 1000,
'shipping_cost' => 0,
'notes' => 'Ubah belanja',
'items' => [
['raw_material_price_id' => $otherPrice->id, 'quantity' => 1],
],
'items' => getPurchaseItemsPayload($otherPrice, 1),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -209,6 +227,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -253,6 +272,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -292,6 +312,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -323,6 +344,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -421,6 +443,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => '',
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertSessionHasErrors('supplier_id');
});
@ -442,6 +465,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => 99999,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
])
->assertSessionHasErrors('supplier_id');
});
@ -482,6 +506,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
]);
approveLatestPurchaseVerificationRequest($verifier);
@ -532,6 +557,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 2),
]);
approveLatestPurchaseVerificationRequest($verifier);
@ -594,6 +620,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 3),
])
->assertRedirect(route('admin.manage.purchases.index'));
@ -632,6 +659,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => getPurchaseItemsPayload($price, 3),
]);
expect((float) $price->fresh()->stock)->toBe(13.0);
@ -754,3 +782,153 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
expect($item->trashed())->toBeTrue();
});
});
describe('Purchase POS New Raw Material Form', function () {
test('store new raw material adds draft item to cart and initializes stock to 0', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key, $imageContent);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Baru',
'unit' => 'yard',
'variant' => 'Standard',
'price' => 25000,
'quantity' => 10,
's3_keys' => [$key],
])
->assertOk()
->assertJsonPath('item.raw_material_name', 'Kain Toyobo Baru')
->assertJsonPath('item.variant', 'Standard')
->assertJsonPath('item.unit_abbreviation', 'yard');
$this->assertDatabaseHas('raw_materials', [
'name' => 'Kain Toyobo Baru',
'unit' => 'yard',
]);
$this->assertDatabaseHas('raw_material_prices', [
'variant' => 'Standard',
'price' => 25000,
'stock' => 0.0,
]);
});
test('store new raw material fails if photo/s3_keys is missing', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Baru',
'unit' => 'yard',
'variant' => 'Standard',
'price' => 25000,
'quantity' => 10,
])
->assertStatus(422)
->assertJsonValidationErrors(['photos', 's3_keys']);
});
test('store new raw material reuses existing raw material if name and unit match', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key1 = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$key2 = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key1, $imageContent);
\Illuminate\Support\Facades\Storage::disk($disk)->put($key2, $imageContent);
// Add first variant
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Unik',
'unit' => 'yard',
'variant' => 'Standard',
'price' => 25000,
'quantity' => 10,
's3_keys' => [$key1],
])
->assertOk();
// Add second variant with same name and unit
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Unik',
'unit' => 'yard',
'variant' => 'Premium',
'price' => 30000,
'quantity' => 5,
's3_keys' => [$key2],
])
->assertOk();
// Check that raw_materials table only has 1 record for this name
$rawMaterialsCount = \App\Models\RawMaterial::where('name', 'Kain Toyobo Unik')->count();
expect($rawMaterialsCount)->toBe(1);
// Check that raw_material_prices has both variants for the same raw_material_id
$rawMaterial = \App\Models\RawMaterial::where('name', 'Kain Toyobo Unik')->first();
$this->assertDatabaseHas('raw_material_prices', [
'raw_material_id' => $rawMaterial->id,
'variant' => 'Standard',
'price' => 25000,
]);
$this->assertDatabaseHas('raw_material_prices', [
'raw_material_id' => $rawMaterial->id,
'variant' => 'Premium',
'price' => 30000,
]);
});
test('store new raw material increments draft item quantity if same variant is added again', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key, $imageContent);
// Add standard variant first time (qty = 10)
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Sama',
'unit' => 'yard',
'variant' => 'Standard',
'price' => 25000,
'quantity' => 10,
's3_keys' => [$key],
])
->assertOk()
->assertJsonPath('item.quantity', '10');
// Add same variant second time (qty = 5)
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
'name' => 'Kain Toyobo Sama',
'unit' => 'yard',
'variant' => 'Standard',
'price' => 25000,
'quantity' => 5,
's3_keys' => [$key],
])
->assertOk()
->assertJsonPath('item.quantity', '15'); // 10 + 5
});
});