diff --git a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php index 6a068e4..30ffe60 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php @@ -31,7 +31,7 @@ public function rules(): array 'retail_stock' => ['required', 'integer', 'min:0'], 'photo_keys' => ['required', 'array', 'min:1', 'max:5'], 'photo_keys.*' => ['required', 'string', 'max:500'], - 'prices' => ['required', 'array', 'size:9'], + 'prices' => ['required', 'array'], 'prices.*.type' => ['required', Rule::in(PriceType::values())], 'prices.*.price' => ['required', 'integer', 'min:0'], ]; diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index e518dec..afef58b 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -19,6 +19,7 @@ use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class TransactionService { @@ -165,6 +166,12 @@ public function store(array $data): Order $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $totalAmount = $subtotal - $discount - ($negoPrice ?? 0); + if ($totalAmount <= 0) { + throw ValidationException::withMessages([ + 'discount' => 'Total harga tidak boleh nol atau kurang.', + ]); + } + $order = Order::create([ 'created_by_id' => auth()->id(), 'customer_id' => $data['customer_id'] ?? null, @@ -232,6 +239,12 @@ public function update(Order $order, array $data): Order $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $totalAmount = $subtotal - $discount - ($negoPrice ?? 0); + if ($totalAmount <= 0) { + throw ValidationException::withMessages([ + 'discount' => 'Total harga tidak boleh nol atau kurang.', + ]); + } + foreach ($itemRows as &$row) { $row['order_id'] = $order->id; } @@ -303,9 +316,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp $variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); $variants = ProductVariant::query() ->whereKey($variantIds) - ->with('productPrices:id,variant_id,type,price') + ->with(['productPrices:id,variant_id,type,price', 'product:id,name']) ->get(); + $variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [ + $v->id => $v->product->name.' - '.$v->name, + ]); + $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $price = $variant->productPrices ->first(fn ($p) => $p->type === $resolvedPriceType); @@ -320,13 +337,28 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp return [$variant->id => $price?->price ?? 0]; }); - return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) { + return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $stockType, &$subtotal, &$totalCost) { $quantity = (int) $item['quantity']; $unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); + $label = $variantLabels[$item['product_variant_id']] ?? ' Produk'; + + if ($unitPrice <= 0) { + throw ValidationException::withMessages([ + 'items' => 'Harga untuk "'.$label.'" belum diatur.', + ]); + } + $itemSubtotal = $unitPrice * $quantity; $subtotal += $itemSubtotal; $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); + + if ($capitalPrice <= 0) { + throw ValidationException::withMessages([ + 'items' => 'Harga modal untuk "'.$label.'" belum diatur.', + ]); + } + $totalCost += $capitalPrice * $quantity; return [ diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index a1693f1..c00ecdb 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -40,10 +40,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', 'productVariants.productPrices:id,variant_id,type,price', ]) - ->when($search, fn($q) => $q->where('name', 'like', "%{$search}%")) - ->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%")) - ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status)) - ->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { + ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) + ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) + ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) + ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { $cq->where('categories.id', $categoryId); })) ->when(($filters['stock'] ?? null) === 'empty', function ($q) { @@ -58,7 +58,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = $paginator->getCollection()->each(function ($product) { $product->productVariants->each(function ($variant) { $media = $variant->getMedia('images'); - $variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); + $variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); }); }); @@ -84,7 +84,7 @@ public function store(array $data): Product // Bulk insert variants $now = now(); - $variantRows = collect($data['variants'])->map(fn($v) => [ + $variantRows = collect($data['variants'])->map(fn ($v) => [ 'product_id' => $product->id, 'name' => $v['name'], 'stock' => $v['stock'], @@ -98,7 +98,7 @@ public function store(array $data): Product // Map variant name -> variant ID $insertedVariants = ProductVariant::where('product_id', $product->id)->get(); - $variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]); + $variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]); // Bulk insert prices $priceRows = []; @@ -144,7 +144,7 @@ public function store(array $data): Product NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Baru', - body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), ); @@ -161,8 +161,8 @@ public function getForEdit(Product $product): array $variants = $product->productVariants->map(function (ProductVariant $variant) { $media = $variant->getMedia('images'); - $photoKeys = $media->map(fn($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray(); - $photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); + $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray(); + $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); return [ 'id' => $variant->id, @@ -172,7 +172,7 @@ public function getForEdit(Product $product): array 'retail_stock' => $variant->retail_stock, 'photo_keys' => $photoKeys, 'photo_urls' => $photoUrls, - 'prices' => $variant->productPrices->map(fn($p) => [ + 'prices' => $variant->productPrices->map(fn ($p) => [ 'type' => $p->type->value, 'price' => $p->price, ]), @@ -230,7 +230,7 @@ public function update(Product $product, array $data): Product $existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds) ->with('media') ->get() - ->mapWithKeys(fn($v) => [$v->id => $v]); + ->mapWithKeys(fn ($v) => [$v->id => $v]); // Collect old stock data + identify changed variants $oldStockDataMap = []; @@ -256,7 +256,7 @@ public function update(Product $product, array $data): Product // Bulk update changed variants (only changed ones, not all) if ($changedIds !== []) { $changedUpdates = collect($data['variants']) - ->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds)); + ->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds)); foreach ($changedUpdates as $variantData) { $existingVariantsMap[$variantData['id']]->update([ @@ -269,11 +269,11 @@ public function update(Product $product, array $data): Product } // Bulk create new variants - $newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id'])); + $newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id'])); $newVariantIdMap = []; if ($newVariantsData->isNotEmpty()) { - $newVariantRows = $newVariantsData->map(fn($v) => [ + $newVariantRows = $newVariantsData->map(fn ($v) => [ 'product_id' => $product->id, 'name' => $v['name'], 'stock' => $v['stock'], @@ -290,7 +290,7 @@ public function update(Product $product, array $data): Product ->whereIn('name', $newVariantsData->pluck('name')->toArray()) ->get(); - $newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray(); + $newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray(); } // Build variant_id lookup: existing by id, new by name @@ -354,7 +354,7 @@ public function update(Product $product, array $data): Product } } - $changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter(); + $changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter(); $this->stockMutationService->recordBulkAdjustment( $changedModels, $oldStockDataMap, @@ -388,7 +388,7 @@ public function update(Product $product, array $data): Product NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Diperbarui', - body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), ); @@ -414,7 +414,7 @@ public function destroy(Product $product): bool NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Dihapus', - body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), ); @@ -439,7 +439,7 @@ public function approve(Product $product): void NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER], title: 'Produk Disetujui', - body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.', url: route('admin.master.products.index'), additionalUser: $product->createdBy ?? null, ); @@ -455,7 +455,7 @@ public function reject(Product $product, string $reason = ''): void NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER], title: 'Produk Ditolak', - body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.', url: route('admin.master.products.index'), additionalUser: $product->createdBy ?? null, ); @@ -471,7 +471,7 @@ public function resubmit(Product $product): void NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER], title: 'Produk Diajukan Ulang', - body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.', + body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.', url: route('admin.master.products.index'), ); } diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 8751011..dafe278 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -50,7 +50,7 @@ public function getForRestock(): array ->first(fn ($price) => $price->type === PriceType::REJECT); $variant->reject_price = $rejectPrice?->price ?? 0; }); - }); + })->toArray(); } public function getForTransaction(): array @@ -74,7 +74,7 @@ public function getForTransaction(): array $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $variant->prices = $prices; }); - }); + })->toArray(); } public function getForEdit(ProductVariant $variant): array diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx index beb4c1c..3785054 100644 --- a/resources/js/pages/admin/manage/transaction/create.tsx +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -236,12 +236,16 @@ export default function TransactionCreate({ const incrementQuantity = useCallback( (variantId: number, amount: number) => { + if (amount > 0 && getUnitPrice(variantId) <= 0) { + toast.error('Harga produk ini belum diatur.'); + return; + } setQuantities((prev) => ({ ...prev, [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), })); }, - [], + [getUnitPrice], ); const cartItems: CartLine[] = (() => { @@ -437,10 +441,14 @@ export default function TransactionCreate({ )}{' '} pcs ·{' '} - {formatCurrency( - stockType === 'reject' - ? (variant.prices?.reject ?? 0) - : (variant.prices?.[priceType] ?? 0), + {getUnitPrice(variant.id) <= 0 ? ( + Harga belum diatur + ) : ( + formatCurrency( + stockType === 'reject' + ? (variant.prices?.reject ?? 0) + : (variant.prices?.[priceType] ?? 0), + ) )}
@@ -490,6 +498,7 @@ export default function TransactionCreate({ type="button" variant="outline" size="icon" + disabled={getUnitPrice(variant.id) <= 0} onClick={() => incrementQuantity( variant.id, @@ -882,7 +891,11 @@ export default function TransactionCreate({ !selectedProductId || Object.values(quantities).every( (q) => q <= 0, - ) + ) || + Object.entries(quantities).some( + ([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0, + ) || + total <= 0 } > {processing diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx index 74e7aeb..6f58eec 100644 --- a/resources/js/pages/admin/manage/transaction/edit.tsx +++ b/resources/js/pages/admin/manage/transaction/edit.tsx @@ -206,12 +206,16 @@ export default function TransactionEdit({ const incrementQuantity = useCallback( (variantId: number, amount: number) => { + if (amount > 0 && getUnitPrice(variantId) <= 0) { + toast.error('Harga produk ini belum diatur.'); + return; + } setQuantities((prev) => ({ ...prev, [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), })); }, - [], + [getUnitPrice], ); const cartItems: CartLine[] = (() => { @@ -416,10 +420,14 @@ export default function TransactionEdit({ )}{' '} pcs ·{' '} - {formatCurrency( - stockType === 'reject' - ? (variant.prices?.reject ?? 0) - : (variant.prices?.[priceType] ?? 0), + {getUnitPrice(variant.id) <= 0 ? ( + Harga belum diatur + ) : ( + formatCurrency( + stockType === 'reject' + ? (variant.prices?.reject ?? 0) + : (variant.prices?.[priceType] ?? 0), + ) )} @@ -469,6 +477,7 @@ export default function TransactionEdit({ type="button" variant="outline" size="icon" + disabled={getUnitPrice(variant.id) <= 0} onClick={() => incrementQuantity( variant.id, @@ -860,7 +869,11 @@ export default function TransactionEdit({ uploading || Object.values(quantities).every( (q) => q <= 0, - ) + ) || + Object.entries(quantities).some( + ([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0, + ) || + total <= 0 } > {processing