From f128ace2ea746f073aba12f527c3772805f04157 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 20 Aug 2026 11:12:38 +0700 Subject: [PATCH] feat: update purchase management to support decimal quantities and improve data handling --- .../Requests/Admin/Manage/PurchaseRequest.php | 4 +- app/Models/PurchaseItem.php | 4 +- app/Services/Admin/Manage/PurchaseService.php | 14 +-- ...antity_columns_in_purchase_items_table.php | 30 ++++++ resources/js/lib/purchase-draft.ts | 4 +- .../js/pages/admin/manage/purchase/create.tsx | 99 ++++++++++--------- .../js/pages/admin/manage/purchase/edit.tsx | 48 ++++----- 7 files changed, 123 insertions(+), 80 deletions(-) create mode 100644 database/migrations/2026_08_20_110913_change_quantity_columns_in_purchase_items_table.php diff --git a/app/Http/Requests/Admin/Manage/PurchaseRequest.php b/app/Http/Requests/Admin/Manage/PurchaseRequest.php index 16fe137..e1d8657 100644 --- a/app/Http/Requests/Admin/Manage/PurchaseRequest.php +++ b/app/Http/Requests/Admin/Manage/PurchaseRequest.php @@ -31,11 +31,11 @@ public function rules(): array 'variants' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'], 'variants.*.variant' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'], 'variants.*.price' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'], - 'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'], + 'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'numeric', 'min:0'], 'variants.*.photo_key' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:500'], 'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'], 'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')], - 'existing_items.*.quantity' => ['required', 'integer', 'min:1'], + 'existing_items.*.quantity' => ['required', 'numeric', 'min:0.01'], 'existing_items.*.unit_price' => ['required', 'integer', 'min:0'], 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')], 'discount' => ['nullable', 'integer', 'min:0'], diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php index 91ad2c7..5a72e1b 100644 --- a/app/Models/PurchaseItem.php +++ b/app/Models/PurchaseItem.php @@ -19,9 +19,9 @@ class PurchaseItem extends Model protected function casts(): array { return [ - 'quantity' => 'integer', + 'quantity' => 'decimal:2', 'unit_price' => 'integer', - 'subtotal' => 'integer', + 'subtotal' => 'decimal:2', ]; } diff --git a/app/Services/Admin/Manage/PurchaseService.php b/app/Services/Admin/Manage/PurchaseService.php index f362205..597557f 100644 --- a/app/Services/Admin/Manage/PurchaseService.php +++ b/app/Services/Admin/Manage/PurchaseService.php @@ -157,7 +157,7 @@ public function getForEdit(Purchase $purchase): array )->toArray(); $existingQuantities = $items->mapWithKeys(fn (PurchaseItem $item) => [ - (int) $item->raw_material_price_id => (int) $item->quantity, + (int) $item->raw_material_price_id => (string) $item->quantity, ])->all(); return [ @@ -194,7 +194,7 @@ private function storeFromExisting(array $data): Purchase $subtotal = 0; $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { - $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']); + $itemSubtotal = $item['unit_price'] * $item['quantity']; $subtotal += $itemSubtotal; return [ @@ -267,7 +267,7 @@ private function storeNew(array $data): Purchase $subtotal = 0; $now = now(); $priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) { - $itemSubtotal = (int) ($v['price'] * $v['stock']); + $itemSubtotal = $v['price'] * $v['stock']; $subtotal += $itemSubtotal; return [ @@ -325,7 +325,7 @@ private function storeNew(array $data): Purchase 'user_id' => auth()->id(), 'quantity' => $v['stock'], 'unit_price' => $v['price'], - 'subtotal' => (int) ($v['price'] * $v['stock']), + 'subtotal' => $v['price'] * $v['stock'], 'created_at' => $now, 'updated_at' => $now, ]; @@ -378,7 +378,7 @@ public function update(Purchase $purchase, array $data): Purchase if (($data['mode'] ?? 'new') === 'existing') { $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { - $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']); + $itemSubtotal = $item['unit_price'] * $item['quantity']; $subtotal += $itemSubtotal; return [ @@ -429,7 +429,7 @@ public function update(Purchase $purchase, array $data): Purchase } if ($price) { - $price->increment('stock', (int) $v['stock']); + $price->increment('stock', $v['stock']); $price->update(['price' => $v['price']]); } else { $price = $rawMaterial->rawMaterialPrices()->create([ @@ -449,7 +449,7 @@ public function update(Purchase $purchase, array $data): Purchase ); } - $itemSubtotal = (int) ($v['price'] * $v['stock']); + $itemSubtotal = $v['price'] * $v['stock']; $subtotal += $itemSubtotal; return [ diff --git a/database/migrations/2026_08_20_110913_change_quantity_columns_in_purchase_items_table.php b/database/migrations/2026_08_20_110913_change_quantity_columns_in_purchase_items_table.php new file mode 100644 index 0000000..caab5a7 --- /dev/null +++ b/database/migrations/2026_08_20_110913_change_quantity_columns_in_purchase_items_table.php @@ -0,0 +1,30 @@ +decimal('quantity', 10, 2)->default(0)->change(); + $table->decimal('subtotal', 14, 2)->default(0)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('purchase_items', function (Blueprint $table) { + $table->unsignedInteger('quantity')->default(0)->change(); + $table->unsignedInteger('subtotal')->default(0)->change(); + }); + } +}; diff --git a/resources/js/lib/purchase-draft.ts b/resources/js/lib/purchase-draft.ts index 91c7eea..f8621c9 100644 --- a/resources/js/lib/purchase-draft.ts +++ b/resources/js/lib/purchase-draft.ts @@ -10,12 +10,12 @@ export type PurchaseDraftData = { variants: Array<{ variant: string; price: number; - stock: number; + stock: string; photo_keys?: string[]; }>; mode?: 'new' | 'existing'; selectedMaterialName?: string; - quantities?: Record; + quantities?: Record; photo?: string; }; diff --git a/resources/js/pages/admin/manage/purchase/create.tsx b/resources/js/pages/admin/manage/purchase/create.tsx index ca07f99..5bccae5 100644 --- a/resources/js/pages/admin/manage/purchase/create.tsx +++ b/resources/js/pages/admin/manage/purchase/create.tsx @@ -17,7 +17,6 @@ import { ConfirmDialog } from '@/components/dialogs'; import { FileUpload, FileUploadMultiple } from '@/components/inputs'; import { ImagePreviewModal } from '@/components/dialogs'; import { InputError } from '@/components/ui'; -import { NumberInput } from '@/components/inputs'; import { RupiahInput } from '@/components/inputs'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -53,7 +52,7 @@ import type { PurchaseCreateData, RawMaterialVariant } from './columns'; type VariantState = { variant: string; price: number; - stock: number; + stock: string; photo: string | null; photoUrl: string | null; uploading: boolean; @@ -65,9 +64,9 @@ type CartLine = { title: string; subtitle: string; price: number; - quantity: number; + quantity: string; onAdjust: (delta: number) => void; - onSet: (value: number) => void; + onSet: (value: string) => void; onRemove: () => void; }; @@ -89,7 +88,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { return draft.variants.map((v) => ({ variant: v.variant, price: v.price, - stock: v.stock, + stock: String(v.stock), photo: v.photo ?? null, photoUrl: v.photo ? getTemporaryUrl(v.photo) : null, uploading: false, @@ -100,7 +99,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { { variant: '', price: 0, - stock: 0, + stock: '0', photo: null, photoUrl: null, uploading: false, @@ -132,11 +131,11 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const [fetchedVariants, setFetchedVariants] = useState>(new Map()); const [loadingVariants, setLoadingVariants] = useState(false); - const [quantities, setQuantities] = useState>(() => + const [quantities, setQuantities] = useState>(() => Object.fromEntries( Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ Number(id), - qty, + String(qty), ]), ), ); @@ -213,14 +212,14 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { }, [fetchedVariants, rawMaterials]); const newSubtotal = variants.reduce( - (sum, v) => sum + Number(v.price) * Number(v.stock), + (sum, v) => sum + Number(v.price) * (parseFloat(v.stock) || 0), 0, ); const existingSubtotal = Object.entries(quantities).reduce( (sum, [priceId, quantity]) => { const price = priceMap.get(Number(priceId)); - return sum + (price ? price.price * quantity : 0); + return sum + (price ? price.price * (parseFloat(quantity) || 0) : 0); }, 0, ); @@ -233,7 +232,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { { variant: '', price: 0, - stock: 0, + stock: '0', photo: null, photoUrl: null, uploading: false, @@ -341,17 +340,17 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { .finally(() => setLoadingVariants(false)); }, [selectedMaterial, mode, fetchedVariants]); - const updateQuantity = useCallback((priceId: number, value: number) => { + const updateQuantity = useCallback((priceId: number, value: string) => { setQuantities((prev) => ({ ...prev, - [priceId]: Math.max(0, value), + [priceId]: value, })); }, []); const incrementQuantity = useCallback((priceId: number, amount: number) => { setQuantities((prev) => ({ ...prev, - [priceId]: Math.max(0, (prev[priceId] ?? 0) + amount), + [priceId]: String(Math.max(0, (parseFloat(prev[priceId]) || 0) + amount)), })); }, []); @@ -360,7 +359,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const updated = [...prev]; updated[index] = { ...updated[index], - stock: Math.max(0, Number(updated[index].stock) + amount), + stock: String(Math.max(0, (parseFloat(updated[index].stock) || 0) + amount)), }; return updated; @@ -372,7 +371,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const lines: CartLine[] = []; for (const [priceId, quantity] of Object.entries(quantities)) { - if (quantity <= 0) { + if ((parseFloat(quantity) || 0) <= 0) { continue; } @@ -390,7 +389,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { quantity, onAdjust: (delta) => incrementQuantity(id, delta), onSet: (value) => updateQuantity(id, value), - onRemove: () => updateQuantity(id, 0), + onRemove: () => updateQuantity(id, '0'), }); } } @@ -410,7 +409,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { onSet: (value) => updateVariant(index, 'stock', value), onRemove: () => removeVariant(index), })) - .filter((line) => line.quantity > 0); + .filter((line) => (parseFloat(line.quantity) || 0) > 0); })(); function formatQuantity(value: number): string { @@ -433,7 +432,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { existing_items: Object.entries(quantities) .map(([priceId, quantity]) => ({ raw_material_price_id: Number(priceId), - quantity: Number(quantity), + quantity: parseFloat(quantity) || 0, unit_price: priceMap.get(Number(priceId))?.price ?? 0, })) .filter((item) => item.quantity > 0), @@ -447,7 +446,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { variants: variantsRef.current.map((v) => ({ variant: v.variant, price: Number(v.price), - stock: Number(v.stock), + stock: parseFloat(v.stock) || 0, photo_key: v.photo, })), }; @@ -727,17 +726,19 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { * - updateVariant( variantIndex, 'stock', - val, + e.target.value, ) } /> @@ -902,12 +903,14 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { price.id } className={ - (quantities[ - price - .id - ] ?? - 0) > - 0 + (parseFloat( + quantities[ + price + .id + ] ?? + '0', + ) > + 0) ? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' : 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' } @@ -958,11 +961,13 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { size="icon" disabled={ !( - quantities[ - price - .id - ] ?? - 0 + parseFloat( + quantities[ + price + .id + ] ?? + '0', + ) > 0 ) } onClick={() => @@ -974,21 +979,23 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { > - updateQuantity( price.id, - val, + e.target.value, ) } /> @@ -1166,7 +1173,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { (mode === 'existing' ? Object.values( quantities, - ).every((q) => q <= 0) + ).every((q) => (parseFloat(q) || 0) <= 0) : !name) } > @@ -1263,7 +1270,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { variant="outline" size="icon-sm" disabled={ - item.quantity <= 0 + (parseFloat(item.quantity) || 0) <= 0 } onClick={() => item.onAdjust(-1) @@ -1271,10 +1278,12 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { > - item.onSet(e.target.value)} /> - - updateQuantity(price.id, val) + value={quantities[price.id] ?? '0'} + onChange={(e) => + updateQuantity(price.id, e.target.value) } /> - item.onSet(e.target.value)} />