feat: add functionality to create new raw materials and store them in the purchase draft, enhancing the purchasing process
This commit is contained in:
parent
c78afb6477
commit
a3954faab6
@ -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);
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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" />
|
||||
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user