feat: enhance raw material management by adding validation for updates and deletions, and improve error handling in the UI

This commit is contained in:
Yoga Pangestu 2026-08-15 22:40:36 +07:00
parent 6fa9224983
commit cc3cd03e41
5 changed files with 100 additions and 22 deletions

View File

@ -9,6 +9,7 @@
use App\Services\Admin\Master\RawMaterial\RawMaterialService; use App\Services\Admin\Master\RawMaterial\RawMaterialService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -55,13 +56,15 @@ public function edit(RawMaterial $rawMaterial): Response
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{ {
return $this->handleAction( try {
fn () => $this->service->update($rawMaterial, $request->validated()), $this->service->update($rawMaterial, $request->validated());
'Bahan baku berhasil diperbarui.', } catch (ValidationException $e) {
'admin.master.raw-materials.index', return back()->withErrors($e->errors());
'admin.master.raw-materials.edit', }
['rawMaterial' => $rawMaterial]
); Inertia::flash('toast', ['type' => 'success', 'message' => 'Bahan baku berhasil diperbarui.']);
return to_route('admin.master.raw-materials.index');
} }
public function destroy(RawMaterial $rawMaterial): RedirectResponse public function destroy(RawMaterial $rawMaterial): RedirectResponse

View File

@ -11,6 +11,7 @@
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RawMaterialService class RawMaterialService
{ {
@ -108,7 +109,7 @@ public function store(array $data): RawMaterial
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Baru', title: 'Bahan Baku Baru',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', body: "Bahan baku \"{$rawMaterial->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
@ -161,12 +162,30 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
->filter() ->filter()
->toArray(); ->toArray();
$rawMaterial->rawMaterialPrices() $variantsToDelete = $rawMaterial->rawMaterialPrices()
->whereNotIn('id', $existingVariantIds) ->whereNotIn('id', $existingVariantIds)
->each(function (RawMaterialPrice $price) { ->get();
$price->clearMediaCollection('images');
$price->delete(); foreach ($variantsToDelete as $price) {
}); if ($price->stock > 0) {
throw ValidationException::withMessages([
'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih memiliki stok.",
]);
}
$activeCuttingUsage = $price->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih digunakan di cutting yang sedang diproses.",
]);
}
$price->clearMediaCollection('images');
$price->delete();
}
$existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds) $existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds)
->with('media') ->with('media')
@ -228,7 +247,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Diperbarui', title: 'Bahan Baku Diperbarui',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Bahan baku \"{$rawMaterial->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
@ -239,8 +258,27 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
public function destroy(RawMaterial $rawMaterial): bool public function destroy(RawMaterial $rawMaterial): bool
{ {
foreach ($rawMaterial->rawMaterialPrices as $price) {
if ($price->stock > 0) {
throw ValidationException::withMessages([
'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih memiliki stok.",
]);
}
$activeCuttingUsage = $price->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih digunakan di cutting yang sedang diproses.",
]);
}
}
$result = DB::transaction(function () use ($rawMaterial) { $result = DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('images');
$price->delete(); $price->delete();
}); });
@ -248,7 +286,7 @@ public function destroy(RawMaterial $rawMaterial): bool
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Dihapus', title: 'Bahan Baku Dihapus',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
@ -266,7 +304,7 @@ public function toggleStatus(RawMaterial $rawMaterial): void
$status = $rawMaterial->is_active ? 'diaktifkan' : 'dinonaktifkan'; $status = $rawMaterial->is_active ? 'diaktifkan' : 'dinonaktifkan';
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Status Bahan Baku Diubah', title: 'Status Bahan Baku Diubah',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.', body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),

View File

@ -9,6 +9,7 @@
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RawMaterialVariantService class RawMaterialVariantService
{ {
@ -85,7 +86,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]), url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]),
@ -96,12 +97,28 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{ {
if ($variant->stock > 0) {
throw ValidationException::withMessages([
'variant' => 'Varian tidak dapat dihapus karena masih memiliki stok.',
]);
}
$activeCuttingUsage = $variant->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'variant' => 'Varian tidak dapat dihapus karena masih digunakan di cutting yang sedang diproses.',
]);
}
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
return $variant->delete(); return $variant->delete();
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),

View File

@ -7,7 +7,7 @@ import {
Plus, Plus,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs'; import { FileUpload } from '@/components/inputs';
@ -134,6 +134,23 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>( const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null, null,
); );
const [removedVariants, setRemovedVariants] = useState<VariantState[]>([]);
const removedVariantsRef = useRef(removedVariants);
removedVariantsRef.current = removedVariants;
const { errors: pageErrors } = usePage<{ errors?: Record<string, string[]> }>().props;
useEffect(() => {
if (pageErrors && Object.keys(pageErrors).length > 0) {
const removed = removedVariantsRef.current;
if (removed.length > 0) {
setVariants((prev) => [...prev, ...removed]);
setRemovedVariants([]);
}
const firstError = Object.values(pageErrors).flat().find(Boolean);
toast.error(firstError ?? 'Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}
}, [pageErrors]);
const confirmRemoveVariant = useCallback((index: number) => { const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index); setDeleteVariantIndex(index);
@ -222,9 +239,6 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
...data, ...data,
...getPayload(), ...getPayload(),
})} })}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
> >
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
@ -555,6 +569,10 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
confirmLabel="Hapus" confirmLabel="Hapus"
onConfirm={() => { onConfirm={() => {
if (deleteVariantIndex !== null) { if (deleteVariantIndex !== null) {
const removed = variants[deleteVariantIndex];
if (removed.id !== null) {
setRemovedVariants((prev) => [...prev, removed]);
}
removeVariant(deleteVariantIndex); removeVariant(deleteVariantIndex);
} }

View File

@ -141,6 +141,7 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
router.delete(destroy.url(deleting.id), { router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null), onSuccess: () => setDeleting(null),
onError: () => setDeleting(null),
}); });
} }
@ -156,6 +157,7 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
}), }),
{ {
onSuccess: () => setDeletingVariant(null), onSuccess: () => setDeletingVariant(null),
onError: () => setDeletingVariant(null),
}, },
); );
} }