From 5dc24b1494760cd0c7841e03efcd7e9384dc193e Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 19 Jun 2026 20:53:42 +0700 Subject: [PATCH] feat: implement product approval workflow by adding verification, rejection, and approval functionalities, including UI updates and permission checks --- app/Enums/Permission.php | 5 +- app/Enums/ProductPendingAction.php | 15 + app/Enums/ProductStatus.php | 23 ++ .../Admin/Master/ProductController.php | 84 +++++- app/Http/Controllers/HomeController.php | 2 + .../Admin/Master/RejectProductRequest.php | 34 +++ app/Models/Product.php | 63 ++++ app/Services/Manage/CuttingService.php | 4 +- app/Services/Manage/OrderService.php | 4 +- app/Services/Master/ProductService.php | 283 +++++++++++++++++- database/factories/ProductFactory.php | 3 + ...add_approval_columns_to_products_table.php | 36 +++ database/seeders/ProductSeeder.php | 3 + .../master/products/ProductGroupedTable.vue | 91 ++++-- .../master/products/RejectProductModal.vue | 101 +++++++ .../master/products/data-table-actions.vue | 125 +++++++- .../master/products/product-status-toggle.vue | 13 +- .../js/pages/admin/master/products/Index.vue | 30 +- resources/js/types/product.ts | 8 + routes/web.php | 8 + 20 files changed, 881 insertions(+), 54 deletions(-) create mode 100644 app/Enums/ProductPendingAction.php create mode 100644 app/Enums/ProductStatus.php create mode 100644 app/Http/Requests/Admin/Master/RejectProductRequest.php create mode 100644 database/migrations/2026_06_19_100001_add_approval_columns_to_products_table.php create mode 100644 resources/js/components/admin/master/products/RejectProductModal.vue diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 3638b6b..ca618bf 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -48,6 +48,7 @@ enum Permission: string case PRODUCTS_UPDATE = 'products.update'; case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status'; + case PRODUCTS_VERIFY = 'products.verify'; case RAW_MATERIALS_VIEW = 'raw-materials.view'; case RAW_MATERIALS_CREATE = 'raw-materials.create'; @@ -150,6 +151,7 @@ public function label(): string self::PRODUCTS_UPDATE => 'Ubah Produk', self::PRODUCTS_DELETE => 'Hapus Produk', self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk', + self::PRODUCTS_VERIFY => 'Setujui/Tolak Produk', self::RAW_MATERIALS_VIEW => 'Lihat Bahan Baku', self::RAW_MATERIALS_CREATE => 'Tambah Bahan Baku', @@ -228,7 +230,8 @@ public function group(): string self::CUSTOMERS_VIEW, self::CUSTOMERS_CREATE, self::CUSTOMERS_UPDATE, self::CUSTOMERS_DELETE => 'Pelanggan', self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE, - self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk', + self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS, + self::PRODUCTS_VERIFY => 'Produk', self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE, self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku', self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE, diff --git a/app/Enums/ProductPendingAction.php b/app/Enums/ProductPendingAction.php new file mode 100644 index 0000000..1b772b0 --- /dev/null +++ b/app/Enums/ProductPendingAction.php @@ -0,0 +1,15 @@ + 'Penghapusan', + }; + } +} diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php new file mode 100644 index 0000000..e9a20e7 --- /dev/null +++ b/app/Enums/ProductStatus.php @@ -0,0 +1,23 @@ + 'Menunggu', + self::APPROVED => 'Disetujui', + self::REJECTED => 'Ditolak', + }; + } +} diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 7586040..8b51abc 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -2,10 +2,13 @@ namespace App\Http\Controllers\Admin\Master; +use App\Enums\Permission; +use App\Enums\ProductStatus; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Master\ProductRequest; +use App\Http\Requests\Admin\Master\RejectProductRequest; use App\Models\Product; use App\Models\ProductVariant; use App\Services\Master\ProductService; @@ -29,15 +32,17 @@ public function index(Request $request): Response $tableQuery = $this->parseDataTableQuery($request); $isActive = $request->string('is_active')->toString(); $categoryId = $request->string('category_id')->toString(); + $status = $request->string('status')->toString(); return Inertia::render('admin/master/products/Index', [ - 'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId), + 'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $status), 'outOfStockGroups' => $this->productService->outOfStockGroups(), 'stockWarningGroups' => $this->productService->stockWarningGroups(), 'categories' => $this->productService->categoryOptions(), 'filters' => $this->dataTableFilters($tableQuery, [ 'is_active' => $isActive, 'category_id' => $categoryId, + 'status' => $status, ]), ]); } @@ -51,9 +56,17 @@ public function create(): Response public function store(ProductRequest $request): RedirectResponse { - $this->productService->create($request->validated()); + $this->productService->create($request->validated(), $request->user()); - $this->flashCreated('Produk'); + $message = $request->user()?->can(Permission::PRODUCTS_VERIFY->value) + ? null + : 'Pengajuan produk berhasil dikirim dan menunggu persetujuan owner.'; + + if ($message) { + $this->flashSuccess($message); + } else { + $this->flashCreated('Produk'); + } return redirect()->route('admin.master.products.index'); } @@ -79,9 +92,17 @@ public function edit(Product $product): Response public function update(ProductRequest $request, Product $product): RedirectResponse { - $this->productService->update($product, $request->validated()); + $this->productService->update($product, $request->validated(), $request->user()); - $this->flashUpdated('Produk'); + $message = $request->user()?->can(Permission::PRODUCTS_VERIFY->value) + ? null + : 'Pengajuan perubahan produk berhasil dikirim dan menunggu persetujuan owner.'; + + if ($message) { + $this->flashSuccess($message); + } else { + $this->flashUpdated('Produk'); + } return redirect()->route('admin.master.products.index'); } @@ -92,7 +113,7 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon 'is_active' => ['required', 'boolean'], ]); - $this->productService->toggleStatus($product, $validated['is_active']); + $this->productService->toggleStatus($product, $validated['is_active'], $request->user()); $this->flashStatusUpdated('produk'); @@ -101,9 +122,56 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon public function destroy(Product $product): RedirectResponse { - $this->productService->delete($product); + $user = auth()->user(); - $this->flashDeleted('Produk'); + if ($user?->can(Permission::PRODUCTS_VERIFY->value)) { + $this->productService->delete($product, $user); + $this->flashDeleted('Produk'); + + return redirect()->route('admin.master.products.index'); + } + + $wasDeleteRequest = $product->status === ProductStatus::APPROVED; + + $this->productService->delete($product, $user); + + if ($wasDeleteRequest) { + $this->flashSuccess('Pengajuan penghapusan produk berhasil dikirim dan menunggu persetujuan owner.'); + } else { + $this->flashDeleted('Produk'); + } + + return redirect()->route('admin.master.products.index'); + } + + public function approve(Product $product): RedirectResponse + { + $user = auth()->user(); + if ($user && ! $user->hasAnyRole(['owner', 'developer'])) { + abort(403); + } + + $this->productService->approve($product, $user); + + $this->flashSuccess('Produk berhasil disetujui.'); + + return redirect()->route('admin.master.products.index'); + } + + public function reject(RejectProductRequest $request, Product $product): RedirectResponse + { + $user = auth()->user(); + if ($user && ! $user->hasAnyRole(['owner', 'developer'])) { + abort(403); + } + + $this->productService->reject( + $product, + $request->validated('reason'), + $user, + ); + + $this->flashSuccess('Produk berhasil ditolak.'); return redirect()->route('admin.master.products.index'); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 59e5bf8..5b723f9 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\ProductStatus; use App\Models\Category; use App\Models\Product; use App\Models\SystemConfiguration; @@ -23,6 +24,7 @@ public function index(): Response $categories = Category::whereHas('products')->get(['id', 'name', 'slug']); $products = Product::where('is_active', true) + ->where('status', ProductStatus::APPROVED) ->with([ 'categories', 'variants' => fn ($query) => $query diff --git a/app/Http/Requests/Admin/Master/RejectProductRequest.php b/app/Http/Requests/Admin/Master/RejectProductRequest.php new file mode 100644 index 0000000..758d6bc --- /dev/null +++ b/app/Http/Requests/Admin/Master/RejectProductRequest.php @@ -0,0 +1,34 @@ +user()?->can(Permission::PRODUCTS_VERIFY->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:500'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'reason' => 'alasan penolakan', + ]; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index b1f172d..c7aec85 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -2,10 +2,16 @@ namespace App\Models; +use App\Enums\ProductPendingAction; +use App\Enums\ProductStatus; +use App\Models\Concerns\HasRejection; use App\Models\Concerns\InteractsWithActivityLog; +use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -13,9 +19,17 @@ #[Guarded(['id'])] #[Sluggable(from: 'name', to: 'slug')] +#[Appends([ + 'status_label', + 'pending_action_label', + 'rejection_reason', + 'is_editable', + 'can_verify', +])] class Product extends Model { use HasFactory; + use HasRejection; use InteractsWithActivityLog; use SoftDeletes; @@ -23,6 +37,10 @@ protected function casts(): array { return [ 'is_active' => 'boolean', + 'status' => ProductStatus::class, + 'pending_action' => ProductPendingAction::class, + 'was_ever_approved' => 'boolean', + 'verified_at' => 'datetime', ]; } @@ -35,4 +53,49 @@ public function variants(): HasMany { return $this->hasMany(ProductVariant::class); } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by_id'); + } + + public function submittedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by_id'); + } + + public function canVerify(): Attribute + { + return Attribute::make( + get: fn () => $this->status === ProductStatus::PENDING, + ); + } + + public function isEditable(): Attribute + { + return Attribute::make( + get: fn () => in_array($this->status, [ProductStatus::PENDING, ProductStatus::REJECTED], true), + ); + } + + public function pendingActionLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->pending_action?->label(), + ); + } + + public function rejectionReason(): Attribute + { + return Attribute::make( + get: fn () => $this->rejection?->reason, + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index f52a5cb..660e068 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -3,6 +3,7 @@ namespace App\Services\Manage; use App\Enums\CuttingStatus; +use App\Enums\ProductStatus; use App\Models\Cutting; use App\Models\CuttingMaterial; use App\Models\CuttingResult; @@ -181,7 +182,8 @@ public function productCatalog(?Cutting $cutting = null): Collection ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($selectedVariantIds): void { - $query->where('is_active', true); + $query->where('is_active', true) + ->where('status', ProductStatus::APPROVED); if ($selectedVariantIds !== []) { $query->orWhereHas( diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index f305b86..2b090b0 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -6,6 +6,7 @@ use App\Enums\OrderStatus; use App\Enums\PaymentType; use App\Enums\PriceType; +use App\Enums\ProductStatus; use App\Enums\ProductStockQuality; use App\Models\Customer; use App\Models\Order; @@ -143,7 +144,8 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($orderVariantIds): void { - $query->where('is_active', true); + $query->where('is_active', true) + ->where('status', ProductStatus::APPROVED); if ($orderVariantIds !== []) { $query->orWhereHas( diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 9b56b3e..3379c01 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -2,15 +2,22 @@ namespace App\Services\Master; +use App\Enums\Permission; +use App\Enums\ProductPendingAction; +use App\Enums\ProductStatus; use App\Models\Category; use App\Models\Product; use App\Models\ProductVariant; +use App\Models\User; use App\Services\Manage\CuttingResultPriceResolver; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; +use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class ProductService { @@ -19,6 +26,7 @@ class ProductService public function __construct( private readonly MediaService $mediaService, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -82,7 +90,9 @@ private function inventoryAlertGroups(callable $filter): array { return ProductVariant::query() ->with(['product', 'media']) - ->whereHas('product', fn (Builder $query) => $query->where('is_active', true)) + ->whereHas('product', fn (Builder $query) => $query + ->where('is_active', true) + ->where('status', ProductStatus::APPROVED)) ->get() ->filter($filter) ->sortBy([ @@ -114,11 +124,12 @@ private function inventoryAlertGroups(callable $filter): array /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ - public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = ''): LengthAwarePaginator + public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = '', string $status = ''): LengthAwarePaginator { $query = Product::query() ->with([ 'categories', + 'rejection', 'variants' => fn ($query) => $query ->with('media') ->orderBy('created_at'), @@ -130,13 +141,18 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca ->orWhere('slug', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%") ->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) - ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) + ->orWhereHas('rejection', fn (Builder $query) => $query->where('reason', 'like', "%{$search}%")); }); }) ->when( $isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1') ) + ->when( + $status !== '', + fn (Builder $query) => $query->where('status', $status) + ) ->when( $categoryId !== '', fn (Builder $query) => $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId)) @@ -181,13 +197,20 @@ public function categoryOptions(): array /** * @param array $validated */ - public function create(array $validated): void + public function create(array $validated, User $user): void { - DB::transaction(function () use ($validated): void { + DB::transaction(function () use ($validated, $user): void { + $autoApprove = $this->userCanAutoApprove($user); + $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, - 'is_active' => true, + 'is_active' => $autoApprove, + 'status' => $autoApprove ? ProductStatus::APPROVED : ProductStatus::PENDING, + 'was_ever_approved' => $autoApprove, + 'submitted_by_id' => $autoApprove ? null : $user->id, + 'verified_at' => $autoApprove ? Carbon::now() : null, + 'verified_by_id' => $autoApprove ? $user->id : null, ]); $product->categories()->sync($validated['category_ids']); @@ -195,17 +218,43 @@ public function create(array $validated): void foreach ($validated['variants'] as $index => $variantData) { $this->createVariant($product, $variantData, $index); } + + if (! $autoApprove) { + $this->notifyOwnersOfPendingProduct($product, 'baru'); + } }); } /** * @param array $validated */ - public function update(Product $product, array $validated): void + public function update(Product $product, array $validated, User $user): void { - DB::transaction(function () use ($validated, $product): void { + $this->ensureEditable($product, 'Produk hanya dapat diubah saat status menunggu atau ditolak.'); + + DB::transaction(function () use ($validated, $product, $user): void { + $autoApprove = $this->userCanAutoApprove($user); + $product->name = $validated['name']; $product->description = $validated['description'] ?? null; + + if ($autoApprove) { + $product->status = ProductStatus::APPROVED; + $product->pending_action = null; + $product->was_ever_approved = true; + $product->verified_at = Carbon::now(); + $product->verified_by_id = $user->id; + $product->submitted_by_id = null; + } else { + $product->status = ProductStatus::PENDING; + $product->pending_action = null; + $product->verified_at = null; + $product->verified_by_id = null; + $product->submitted_by_id = $user->id; + $product->is_active = false; + $product->rejection()?->delete(); + } + $product->save(); $product->categories()->sync($validated['category_ids']); @@ -237,16 +286,185 @@ public function update(Product $product, array $validated): void $this->createVariant($product, $variantData, $index); } + + if (! $autoApprove) { + $this->notifyOwnersOfPendingProduct($product, 'diperbarui'); + } }); } - public function toggleStatus(Product $product, bool $isActive): void + public function toggleStatus(Product $product, bool $isActive, User $user): void { + if (! $this->userCanAutoApprove($user)) { + throw ValidationException::withMessages([ + 'is_active' => 'Perubahan status produk memerlukan persetujuan owner.', + ]); + } + + $this->ensureApproved($product, 'Status produk hanya dapat diubah setelah disetujui.'); + $product->is_active = $isActive; $product->save(); } - public function delete(Product $product): void + public function delete(Product $product, User $user): void + { + if ($this->userCanAutoApprove($user)) { + $this->performDelete($product); + + return; + } + + if ($product->status === ProductStatus::PENDING && $product->pending_action === null) { + $this->performDelete($product); + + return; + } + + if ($product->status === ProductStatus::REJECTED && $product->pending_action === null) { + $this->performDelete($product); + + return; + } + + $this->ensureApproved($product, 'Pengajuan penghapusan hanya dapat dilakukan untuk produk yang sudah disetujui.'); + + DB::transaction(function () use ($product, $user): void { + $product->status = ProductStatus::PENDING; + $product->pending_action = ProductPendingAction::DELETE; + $product->is_active = false; + $product->submitted_by_id = $user->id; + $product->verified_at = null; + $product->verified_by_id = null; + $product->rejection()?->delete(); + $product->save(); + + $this->notifyOwnersOfPendingProduct($product, 'dihapus'); + }); + } + + public function approve(Product $product, User $user): void + { + $this->ensurePending($product, 'Produk ini sudah diverifikasi.'); + + if ($product->pending_action === ProductPendingAction::DELETE) { + $submittedById = $product->submitted_by_id; + $productName = $product->name; + + DB::transaction(function () use ($product): void { + $this->performDelete($product); + }); + + if ($submittedById) { + $this->pushNotificationService->sendToUser( + '✅ Penghapusan Produk Disetujui', + "Pengajuan penghapusan produk {$productName} telah disetujui.", + $submittedById, + '/admin/master/products', + ); + } + + return; + } + + DB::transaction(function () use ($product, $user): void { + $product->status = ProductStatus::APPROVED; + $product->pending_action = null; + $product->is_active = true; + $product->was_ever_approved = true; + $product->verified_at = Carbon::now(); + $product->verified_by_id = $user->id; + $product->save(); + + $product->rejection()?->delete(); + }); + + $product->loadMissing('submittedBy'); + if ($product->submitted_by_id) { + $this->pushNotificationService->sendToUser( + '✅ Produk Disetujui', + "Produk {$product->name} telah disetujui dan aktif.", + $product->submitted_by_id, + '/admin/master/products', + ); + } + } + + public function reject(Product $product, string $reason, User $user): void + { + $this->ensurePending($product, 'Produk ini sudah diverifikasi.'); + + if ($product->pending_action === ProductPendingAction::DELETE) { + DB::transaction(function () use ($product, $user, $reason): void { + $product->status = ProductStatus::APPROVED; + $product->pending_action = null; + $product->is_active = true; + $product->verified_at = Carbon::now(); + $product->verified_by_id = $user->id; + $product->save(); + + $product->rejection()->create([ + 'reason' => $reason, + 'rejected_by_id' => $user->id, + ]); + }); + + $product->loadMissing('submittedBy'); + if ($product->submitted_by_id) { + $this->pushNotificationService->sendToUser( + '❌ Penghapusan Produk Ditolak', + "Pengajuan penghapusan produk {$product->name} ditolak dengan alasan: '{$reason}'.", + $product->submitted_by_id, + '/admin/master/products', + ); + } + + return; + } + + DB::transaction(function () use ($product, $user, $reason): void { + $submittedById = $product->submitted_by_id; + $productName = $product->name; + + if (! $product->was_ever_approved) { + $this->performDelete($product); + + if ($submittedById) { + $this->pushNotificationService->sendToUser( + '❌ Produk Ditolak', + "Pengajuan produk {$productName} ditolak dengan alasan: '{$reason}'.", + $submittedById, + '/admin/master/products', + ); + } + + return; + } + + $product->status = ProductStatus::REJECTED; + $product->pending_action = null; + $product->is_active = false; + $product->verified_at = Carbon::now(); + $product->verified_by_id = $user->id; + $product->save(); + + $product->rejection()->create([ + 'reason' => $reason, + 'rejected_by_id' => $user->id, + ]); + + if ($submittedById) { + $this->pushNotificationService->sendToUser( + '❌ Produk Ditolak', + "Pengajuan produk {$productName} ditolak dengan alasan: '{$reason}'.", + $submittedById, + '/admin/master/products', + ); + } + }); + } + + private function performDelete(Product $product): void { DB::transaction(function () use ($product): void { $product->variants()->each(function (ProductVariant $variant): void { @@ -254,13 +472,56 @@ public function delete(Product $product): void }); $product->variants()->delete(); $product->categories()->detach(); + $product->rejection()?->delete(); $product->delete(); }); } + private function notifyOwnersOfPendingProduct(Product $product, string $actionLabel): void + { + $this->pushNotificationService->sendToRoles( + '📦 Pengajuan Produk Baru', + "Produk {$product->name} {$actionLabel} dan menunggu persetujuan owner.", + ['owner', 'developer'], + '/admin/master/products', + ); + } + + private function userCanAutoApprove(User $user): bool + { + return $user->can(Permission::PRODUCTS_VERIFY->value); + } + + private function ensurePending(Product $product, string $message): void + { + if ($product->status !== ProductStatus::PENDING) { + throw ValidationException::withMessages([ + 'product' => $message, + ]); + } + } + + private function ensureApproved(Product $product, string $message): void + { + if ($product->status !== ProductStatus::APPROVED) { + throw ValidationException::withMessages([ + 'product' => $message, + ]); + } + } + + private function ensureEditable(Product $product, string $message): void + { + if (! in_array($product->status, [ProductStatus::PENDING, ProductStatus::REJECTED], true)) { + throw ValidationException::withMessages([ + 'product' => $message, + ]); + } + } + private function applySorting(Builder $query, string $sort, string $direction): void { - if (in_array($sort, ['name', 'slug', 'is_active'], true)) { + if (in_array($sort, ['name', 'slug', 'is_active', 'status'], true)) { $query->orderBy($sort, $direction); return; diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 9bf337e..805f8c1 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\ProductStatus; use App\Models\Product; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -20,6 +21,8 @@ public function definition(): array 'slug' => Str::slug($name), 'description' => fake()->optional()->paragraph(), 'is_active' => true, + 'status' => ProductStatus::APPROVED, + 'was_ever_approved' => true, ]; } diff --git a/database/migrations/2026_06_19_100001_add_approval_columns_to_products_table.php b/database/migrations/2026_06_19_100001_add_approval_columns_to_products_table.php new file mode 100644 index 0000000..b1cd24d --- /dev/null +++ b/database/migrations/2026_06_19_100001_add_approval_columns_to_products_table.php @@ -0,0 +1,36 @@ +string('status', 20)->default(ProductStatus::PENDING->value)->after('is_active'); + $table->string('pending_action', 20)->nullable()->after('status'); + $table->boolean('was_ever_approved')->default(false)->after('pending_action'); + $table->timestamp('verified_at')->nullable()->after('was_ever_approved'); + $table->foreignId('verified_by_id')->nullable()->after('verified_at')->constrained('users')->nullOnDelete(); + $table->foreignId('submitted_by_id')->nullable()->after('verified_by_id')->constrained('users')->nullOnDelete(); + }); + + DB::table('products')->update([ + 'status' => ProductStatus::APPROVED->value, + 'was_ever_approved' => true, + ]); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropConstrainedForeignId('submitted_by_id'); + $table->dropConstrainedForeignId('verified_by_id'); + $table->dropColumn(['status', 'pending_action', 'was_ever_approved', 'verified_at']); + }); + } +}; diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 2896d3e..c8627f2 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Enums\ProductStatus; use App\Models\Category; use App\Models\Product; use App\Models\ProductVariant; @@ -77,6 +78,8 @@ public function run(): void 'slug' => str()->slug($productData['name']), 'description' => $productData['description'], 'is_active' => true, + 'status' => ProductStatus::APPROVED, + 'was_ever_approved' => true, ]); $product->categories()->sync( diff --git a/resources/js/components/admin/master/products/ProductGroupedTable.vue b/resources/js/components/admin/master/products/ProductGroupedTable.vue index ee1f864..928405e 100644 --- a/resources/js/components/admin/master/products/ProductGroupedTable.vue +++ b/resources/js/components/admin/master/products/ProductGroupedTable.vue @@ -28,10 +28,9 @@ import type { } from '@/types/data-table'; import { PRICE_TYPES, - PRICE_TYPE_LABELS - + PRICE_TYPE_LABELS, } from '@/types/product'; -import type {ProductListItem} from '@/types/product'; +import type { ProductListItem } from '@/types/product'; const props = defineProps<{ products: ProductListItem[]; @@ -47,6 +46,7 @@ const search = defineModel('search', { default: '' }); const emit = defineEmits<{ 'filter-change': [key: string, value: string]; 'filters-reset': []; + reject: [product: ProductListItem]; }>(); const showingCount = computed(() => props.products.length); @@ -72,36 +72,81 @@ function formatStock(value: number): string { function rowNumber(index: number): number { return (props.firstItem ?? 1) + index; } + +function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (status) { + case 'approved': + return 'default'; + case 'rejected': + return 'destructive'; + default: + return 'outline'; + } +}