feat: implement stock validation on purchase removal and add global toast error handling for flash messages

This commit is contained in:
Yoga Pangestu 2026-05-02 17:57:32 +07:00
parent 2d5d0b8eb1
commit 23cc07596c
17 changed files with 159 additions and 25 deletions

View File

@ -79,7 +79,11 @@ public function store(OrderRequest $request): RedirectResponse
'price_type' => $item['price_type'], 'price_type' => $item['price_type'],
]); ]);
Product::find($item['product_id'])->decrement('stock', $item['qty']); $product = Product::find($item['product_id']);
if ($product->stock < $item['qty']) {
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
}
$product->decrement('stock', $item['qty']);
} }
OrderItem::where('user_id', auth()->id()) OrderItem::where('user_id', auth()->id())
@ -88,6 +92,8 @@ public function store(OrderRequest $request): RedirectResponse
}); });
return redirect()->route('order.index')->with('success', 'Pesanan berhasil disimpan'); return redirect()->route('order.index')->with('success', 'Pesanan berhasil disimpan');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to store order', [ LogHelper::logException($e, 'Failed to store order', [
'customer_name' => $validated['customer_name'], 'customer_name' => $validated['customer_name'],
@ -166,11 +172,17 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
'price_type' => $item['price_type'], 'price_type' => $item['price_type'],
]); ]);
Product::find($item['product_id'])->decrement('stock', $item['qty']); $product = Product::find($item['product_id']);
if ($product->stock < $item['qty']) {
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
}
$product->decrement('stock', $item['qty']);
} }
}); });
return redirect()->route('order.index')->with('success', 'Pesanan berhasil diperbarui'); return redirect()->route('order.index')->with('success', 'Pesanan berhasil diperbarui');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to update order', [ LogHelper::logException($e, 'Failed to update order', [
'order_id' => $order->id, 'order_id' => $order->id,

View File

@ -106,6 +106,9 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
]); ]);
foreach ($purchase->items as $item) { foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dibatalkan. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity); $item->product->decrement('stock', $item->quantity);
} }
@ -125,6 +128,8 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
}); });
return redirect()->route('purchase.index')->with('success', 'Data berhasil diperbarui'); return redirect()->route('purchase.index')->with('success', 'Data berhasil diperbarui');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to update purchase', [ LogHelper::logException($e, 'Failed to update purchase', [
'purchase_id' => $purchase->id, 'purchase_id' => $purchase->id,
@ -143,12 +148,17 @@ public function destroy(Purchase $purchase): RedirectResponse
try { try {
DB::transaction(function () use ($purchase) { DB::transaction(function () use ($purchase) {
foreach ($purchase->items as $item) { foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dihapus. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity); $item->product->decrement('stock', $item->quantity);
} }
$purchase->delete(); $purchase->delete();
}); });
return redirect()->back()->with('success', 'Data berhasil dihapus'); return redirect()->back()->with('success', 'Data berhasil dihapus');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->with('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to delete purchase', [ LogHelper::logException($e, 'Failed to delete purchase', [
'purchase_id' => $purchase->id, 'purchase_id' => $purchase->id,
@ -164,9 +174,12 @@ public function bulkDestroy(Request $request): RedirectResponse
try { try {
DB::transaction(function () use ($ids) { DB::transaction(function () use ($ids) {
$purchases = Purchase::with('items')->whereIn('id', $ids)->get(); $purchases = Purchase::with('items.product')->whereIn('id', $ids)->get();
foreach ($purchases as $purchase) { foreach ($purchases as $purchase) {
foreach ($purchase->items as $item) { foreach ($purchase->items as $item) {
if ($item->product->stock < $item->quantity) {
throw new \InvalidArgumentException("Stok produk {$item->product->name} tidak mencukupi untuk dihapus. Sisa stok: {$item->product->stock}");
}
$item->product->decrement('stock', $item->quantity); $item->product->decrement('stock', $item->quantity);
} }
$purchase->delete(); $purchase->delete();
@ -174,6 +187,8 @@ public function bulkDestroy(Request $request): RedirectResponse
}); });
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus'); return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
} catch (\InvalidArgumentException $e) {
return redirect()->back()->with('error', $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
LogHelper::logException($e, 'Failed to bulk delete purchases', [ LogHelper::logException($e, 'Failed to bulk delete purchases', [
'ids' => $ids, 'ids' => $ids,

View File

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

View File

@ -180,7 +180,12 @@ return;
post(orderRoutes.store().url, { post(orderRoutes.store().url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -195,7 +195,12 @@ return;
patch(orderRoutes.update(order.id).url, { patch(orderRoutes.update(order.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -165,7 +165,12 @@ return;
post(purchaseRoutes.store().url, { post(purchaseRoutes.store().url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -170,7 +170,12 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
patch(purchaseRoutes.update(purchase.id).url, { patch(purchaseRoutes.update(purchase.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -20,7 +20,12 @@ export function usePurchaseIndex() {
if (purchaseToDelete) { if (purchaseToDelete) {
router.delete(purchaseRoutes.destroy(purchaseToDelete.id).url, { router.delete(purchaseRoutes.destroy(purchaseToDelete.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setPurchaseToDelete(null); setPurchaseToDelete(null);
setRowSelection({}); setRowSelection({});
@ -35,7 +40,12 @@ export function usePurchaseIndex() {
_method: 'DELETE' _method: 'DELETE'
}, { }, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false); setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]); setRowsToDelete([]);
setRowSelection({}); setRowSelection({});

View File

@ -113,7 +113,12 @@ export default function ProductCreate({ categories }: { categories: Category[] }
post(productRoutes.store().url, { post(productRoutes.store().url, {
forceFormData: true, forceFormData: true,
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -165,7 +165,12 @@ export default function ProductEdit({ product, categories }: { product: Product,
post(productRoutes.update(product.id).url, { post(productRoutes.update(product.id).url, {
forceFormData: true, forceFormData: true,
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -21,7 +21,12 @@ export function useProductIndex() {
if (productToDelete) { if (productToDelete) {
router.delete(productRoutes.destroy(productToDelete.id).url, { router.delete(productRoutes.destroy(productToDelete.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setProductToDelete(null); setProductToDelete(null);
setRowSelection({}); setRowSelection({});
@ -36,7 +41,12 @@ export function useProductIndex() {
_method: 'DELETE' _method: 'DELETE'
}, { }, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false); setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]); setRowsToDelete([]);
setRowSelection({}); setRowSelection({});
@ -48,7 +58,14 @@ export function useProductIndex() {
router.patch(productRoutes.toggleStatus(id).url, {}, { router.patch(productRoutes.toggleStatus(id).url, {}, {
preserveState: true, preserveState: true,
preserveScroll: true, preserveScroll: true,
onSuccess: (response: any) => toast.success(response.props.flash.success), onSuccess: (response: any) => {
const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
},
}); });
}; };

View File

@ -50,7 +50,12 @@ export default function UserCreate({ roles }: { roles: any[] }) {
e.preventDefault(); e.preventDefault();
post(userRoutes.store().url, { post(userRoutes.store().url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -51,7 +51,12 @@ export default function UserEdit({ user, roles }: { user: any, roles: any[] }) {
e.preventDefault(); e.preventDefault();
post(userRoutes.update(user.id).url, { post(userRoutes.update(user.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -22,7 +22,12 @@ export function useUserIndex() {
if (userToReset) { if (userToReset) {
router.patch(userRoutes.resetPassword(userToReset.id).url, {}, { router.patch(userRoutes.resetPassword(userToReset.id).url, {}, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsResetPasswordOpen(false); setIsResetPasswordOpen(false);
setUserToReset(null); setUserToReset(null);
}, },
@ -39,7 +44,12 @@ export function useUserIndex() {
if (userToDelete) { if (userToDelete) {
router.delete(userRoutes.destroy(userToDelete.id).url, { router.delete(userRoutes.destroy(userToDelete.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setUserToDelete(null); setUserToDelete(null);
setRowSelection({}); setRowSelection({});
@ -54,7 +64,12 @@ export function useUserIndex() {
_method: 'DELETE' _method: 'DELETE'
}, { }, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false); setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]); setRowsToDelete([]);
setRowSelection({}); setRowSelection({});
@ -67,7 +82,12 @@ export function useUserIndex() {
preserveState: true, preserveState: true,
preserveScroll: true, preserveScroll: true,
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -30,7 +30,12 @@ export default function RoleCreate({
e.preventDefault(); e.preventDefault();
post(roleRoutes.store().url, { post(roleRoutes.store().url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -32,7 +32,12 @@ export default function RoleEdit({
e.preventDefault(); e.preventDefault();
put(roleRoutes.update(role.id).url, { put(roleRoutes.update(role.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
}, },
}); });
}; };

View File

@ -20,7 +20,12 @@ export function useRoleIndex() {
if (roleToDelete) { if (roleToDelete) {
router.delete(roleRoutes.destroy(roleToDelete.id).url, { router.delete(roleRoutes.destroy(roleToDelete.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setRoleToDelete(null); setRoleToDelete(null);
setRowSelection({}); setRowSelection({});
@ -35,7 +40,12 @@ export function useRoleIndex() {
_method: 'DELETE' _method: 'DELETE'
}, { }, {
onSuccess: (response: any) => { onSuccess: (response: any) => {
toast.success(response.props.flash.success); const flash = response.props.flash;
if (flash?.error) {
toast.error(flash.error);
} else if (flash?.success) {
toast.success(flash.success);
}
setIsBulkDeleteDialogOpen(false); setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]); setRowsToDelete([]);
setRowSelection({}); setRowSelection({});