feat: implement product stock management with automated inventory tracking during purchases

This commit is contained in:
Yoga Pangestu 2026-04-20 23:15:31 +07:00
parent f1cda67e23
commit c48d2ecd18
9 changed files with 80 additions and 15 deletions

View File

@ -56,6 +56,8 @@ public function store(PurchaseRequest $request): RedirectResponse
'unit_price' => $item['unit_price'],
'total_price' => $item['quantity'] * $item['unit_price'],
]);
Product::find($item['product_id'])->increment('stock', $item['quantity']);
}
PurchaseItem::where('user_id', auth()->id())
@ -89,6 +91,10 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
'total' => $total,
]);
foreach ($purchase->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
$purchase->items()->delete();
foreach ($validated['items'] as $item) {
@ -99,6 +105,8 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
'unit_price' => $item['unit_price'],
'total_price' => $item['quantity'] * $item['unit_price'],
]);
Product::find($item['product_id'])->increment('stock', $item['quantity']);
}
});
@ -107,7 +115,12 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
public function destroy(Purchase $purchase): RedirectResponse
{
$purchase->delete();
DB::transaction(function () use ($purchase) {
foreach ($purchase->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
$purchase->delete();
});
return redirect()->back()->with('success', 'Data berhasil dihapus');
}
@ -116,7 +129,15 @@ public function bulkDestroy(Request $request): RedirectResponse
{
$ids = $request->input('ids');
Purchase::whereIn('id', $ids)->delete();
DB::transaction(function () use ($ids) {
$purchases = Purchase::with('items')->whereIn('id', $ids)->get();
foreach ($purchases as $purchase) {
foreach ($purchase->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
$purchase->delete();
}
});
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
}

View File

@ -87,6 +87,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
$product->update([
'name' => $validated['name'],
'description' => $validated['description'],
'stock' => $validated['stock'] ?? 0,
]);
$product->categories()->sync($validated['category_ids'] ?? []);

View File

@ -40,6 +40,7 @@ public function rules(): array
'images.*' => ['image', 'max:5120'],
'delete_image_ids' => ['nullable', 'array'],
'delete_image_ids.*' => ['integer'],
'stock' => ['required', 'integer', 'min:0'],
];
}
}

View File

@ -27,6 +27,7 @@ protected function casts(): array
{
return [
'is_active' => 'boolean',
'stock' => 'integer',
];
}

View File

@ -16,6 +16,7 @@ public function up(): void
$table->string('name', 100);
$table->string('slug', 100);
$table->text('description')->nullable();
$table->integer('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();

View File

@ -41,6 +41,8 @@ public function run(): void
'total_price' => $totalPrice,
]);
$product->increment('stock', $quantity);
$total += $totalPrice;
}

View File

@ -67,6 +67,7 @@ export default function ProductEdit({ product, categories }: { product: Product,
thumbnail: File | null;
images: File[];
delete_image_ids: number[];
stock: string;
_method: string;
}>({
name: product.name || '',
@ -82,6 +83,7 @@ export default function ProductEdit({ product, categories }: { product: Product,
thumbnail: null,
images: [],
delete_image_ids: [],
stock: product.stock?.toString() || '0',
_method: 'PATCH',
});
@ -179,19 +181,36 @@ export default function ProductEdit({ product, categories }: { product: Product,
<CardTitle>Informasi Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Gamis Wanita'
maxLength={100}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Field>
<Label htmlFor="name" required>Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Gamis Wanita'
maxLength={100}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
<Field>
<Label htmlFor="stock" required>Stok</Label>
<NumericFormat
id="stock"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
value={data.stock}
onValueChange={(values) => setData('stock', values.value)}
placeholder="0"
autoComplete='off'
/>
{errors.stock && <p className="text-xs text-red-500">{errors.stock}</p>}
</Field>
</div>
<Field>
<Label htmlFor="category_ids" required>Kategori</Label>

View File

@ -105,6 +105,24 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
},
meta: { title: "Harga" },
},
{
accessorKey: "stock",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Stok" />
),
cell: ({ row }) => {
const product = row.original;
return (
<div className="flex items-center gap-2">
<Badge variant={product.stock <= 10 ? "destructive" : "outline"} className='font-mono'>
{product.stock}
</Badge>
</div>
);
},
meta: { title: "Stok" },
},
{
accessorKey: "is_active",
header: "Status",

View File

@ -25,6 +25,7 @@ export interface Product {
created_at: string;
updated_at: string;
deleted_at: string | null;
stock: number;
prices?: ProductPrice[];
categories?: Category[];
thumbnail_url?: string | null;