From 897d9c62b792144ebd48996b45fc11635de7c565 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 8 Jul 2026 16:09:57 +0700 Subject: [PATCH] feat: enhance ProductController and ProductService to support owner verification permissions; add ProductVariantEditModal for editing product variants and implement JSON response for product details --- .../Admin/Master/ProductController.php | 43 +++- app/Services/Master/ProductService.php | 59 ++++- .../products/form/ProductVariantEditModal.vue | 239 ++++++++++++++++++ .../products/table/ProductGroupedTable.vue | 29 ++- routes/web.php | 3 + tests/Feature/Admin/Master/ProductTest.php | 68 ++++- 6 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 resources/js/pages/admin/master/products/form/ProductVariantEditModal.vue diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index f9eeb80..3cc48cd 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin\Master; +use App\Enums\Permission; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; @@ -10,6 +11,8 @@ use App\Models\Product; use App\Services\Master\CategoryService; use App\Services\Master\ProductService; +use App\Support\Media\MediaPresenter; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -53,7 +56,11 @@ public function store(ProductRequest $request): RedirectResponse { $this->productService->create($request->validated(), $request->user()); - $this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.'); + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashCreated('Produk'); + } else { + $this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.'); + } return redirect()->route('admin.master.products.index'); } @@ -70,7 +77,11 @@ public function update(ProductRequest $request, Product $product): RedirectRespo { $this->productService->update($product, $request->validated(), $request->user()); - $this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.'); + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashUpdated('Produk'); + } else { + $this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.'); + } return redirect()->route('admin.master.products.index'); } @@ -79,7 +90,11 @@ public function destroy(Request $request, Product $product): RedirectResponse { $this->productService->delete($product, $request->user()); - $this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.'); + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashDeleted('Produk'); + } else { + $this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.'); + } return redirect()->route('admin.master.products.index'); } @@ -88,8 +103,28 @@ public function toggleStatus(ToggleStatusRequest $request, Product $product): Re { $this->productService->toggleStatus($product, $request->validated(), $request->user()); - $this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.'); + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashStatusUpdated('produk'); + } else { + $this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.'); + } return back(); } + + public function show(Product $product): JsonResponse + { + $product->load([ + 'categories', + 'variants' => fn ($query) => $query + ->with(['media', 'prices']) + ->orderBy('created_at'), + ]); + + $product->variants->each(function ($variant): void { + $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + }); + + return response()->json($product); + } } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index d0aa14c..6ea1163 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -246,13 +246,58 @@ function () use ($validated, $product, $user, $isOwner): void { $this->cacheForget('homepage:page_data'); if (! $isOwner) { - $this->notifyForPendingRequest( - $user, - 'Ubah Produk', - "Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.", - route('admin.master.products.index', ['search' => $product->name]), - $product->name, - ); + $changedVariants = []; + foreach ($validated['variants'] as $variantData) { + if (! empty($variantData['id'])) { + $originalVariant = $product->variants->firstWhere('id', $variantData['id']); + if ($originalVariant) { + $isChanged = false; + if ($originalVariant->name !== $variantData['name']) { + $isChanged = true; + } + if (rtrim(rtrim(number_format((float) $originalVariant->stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $variantData['stock'], 4, '.', ''), '0'), '.')) { + $isChanged = true; + } + if (rtrim(rtrim(number_format((float) $originalVariant->retail_stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $variantData['retail_stock'], 4, '.', ''), '0'), '.')) { + $isChanged = true; + } + if (! empty($variantData['prices'])) { + foreach ($variantData['prices'] as $type => $priceValue) { + $originalPrice = $originalVariant->prices->firstWhere('type', $type); + if (! $originalPrice || $originalPrice->price !== (int) $priceValue) { + $isChanged = true; + break; + } + } + } + + if ($isChanged) { + $changedVariants[] = $variantData['name']; + } + } + } else { + $changedVariants[] = $variantData['name']; + } + } + + if (! empty($changedVariants)) { + $variantsStr = implode(', ', $changedVariants); + $this->notifyForPendingRequest( + $user, + 'Ubah Varian Produk', + "Pengajuan ubah varian '{$variantsStr}' pada produk '{$product->name}' menunggu verifikasi owner.", + route('admin.master.products.index', ['search' => $product->name]), + $product->name, + ); + } else { + $this->notifyForPendingRequest( + $user, + 'Ubah Produk', + "Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.", + route('admin.master.products.index', ['search' => $product->name]), + $product->name, + ); + } } } diff --git a/resources/js/pages/admin/master/products/form/ProductVariantEditModal.vue b/resources/js/pages/admin/master/products/form/ProductVariantEditModal.vue new file mode 100644 index 0000000..e5fd5fc --- /dev/null +++ b/resources/js/pages/admin/master/products/form/ProductVariantEditModal.vue @@ -0,0 +1,239 @@ + + + diff --git a/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue b/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue index be5326f..545583e 100644 --- a/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue +++ b/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue @@ -1,5 +1,6 @@ diff --git a/routes/web.php b/routes/web.php index 528f499..3585a23 100644 --- a/routes/web.php +++ b/routes/web.php @@ -115,6 +115,9 @@ ]) ->name('edit'); + Route::get('{product}', [ProductController::class, 'show']) + ->name('show'); + Route::put('{product}', [ProductController::class, 'update']) ->middleware([ 'permission:'.Permission::PRODUCTS_UPDATE->value, diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php index a1fd206..a994691 100644 --- a/tests/Feature/Admin/Master/ProductTest.php +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -872,7 +872,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10]); ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 20]); - expect($product->fresh()->total_stock_formatted)->toBe('30'); + expect($product->fresh(['variants'])->total_stock_formatted)->toBe('30'); }); test('product has slug auto-generated', function () { @@ -1097,3 +1097,69 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock expect($variant->fresh()->name)->toBe($originalVariantName); }); }); + +// ─── Show JSON ──────────────────────────────────────────── + +describe('Product Show JSON', function () { + test('authenticated user with permission can get product details in JSON', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $product = createProductWithVariants(); + + $response = $this->actingAs($user) + ->get(route('admin.master.products.show', $product)) + ->assertOk(); + + $response->assertJsonStructure([ + 'id', + 'name', + 'description', + 'categories' => [ + '*' => [ + 'id', + 'name', + ], + ], + 'variants' => [ + '*' => [ + 'id', + 'product_id', + 'name', + 'stock', + 'reject_stock', + 'retail_stock', + 'prices' => [ + '*' => [ + 'id', + 'variant_id', + 'type', + 'price', + ], + ], + 'images', + ], + ], + ]); + + $data = $response->json(); + expect($data['id'])->toBe($product->id); + expect($data['variants'])->toHaveCount(2); + }); + + test('guest cannot get product details in JSON', function () { + $product = createProductWithVariants(); + + $this->get(route('admin.master.products.show', $product)) + ->assertRedirect(route('login')); + }); + + test('user without view permission cannot get product details in JSON', function () { + $user = User::factory()->create(); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.show', $product)) + ->assertForbidden(); + }); +});