feat: implement product approval workflow by adding verification, rejection, and approval functionalities, including UI updates and permission checks

This commit is contained in:
Yoga Pangestu 2026-06-19 20:53:42 +07:00
parent 2a2cc0fb96
commit 5dc24b1494
20 changed files with 881 additions and 54 deletions

View File

@ -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,

View File

@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum ProductPendingAction: string
{
case DELETE = 'delete';
public function label(): string
{
return match ($this) {
self::DELETE => 'Penghapusan',
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum ProductStatus: string
{
use ProvidesEnumOptions;
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::PENDING => 'Menunggu',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
}

View File

@ -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');
}

View File

@ -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

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class RejectProductRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::PRODUCTS_VERIFY->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:500'],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'reason' => 'alasan penolakan',
];
}
}

View File

@ -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(),
);
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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<string, mixed> $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<string, mixed> $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;

View File

@ -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,
];
}

View File

@ -0,0 +1,36 @@
<?php
use App\Enums\ProductStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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(

View File

@ -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<string>('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';
}
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
<DataTableToolbar
v-model:search="search"
:filter-defs="filterDefs"
:filter-values="filterValues"
@filter-change="(key, value) => emit('filter-change', key, value)"
@filters-reset="emit('filters-reset')"
/>
<div v-if="products.length" class="space-y-4">
<div v-for="(product, index) in products" :key="product.id" class="overflow-hidden rounded-md border">
<div
v-for="(product, index) in products"
:key="product.id"
class="overflow-hidden rounded-md border"
>
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<h3 class="font-medium leading-tight">
{{ product.name }}
</h3>
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ product.name }}
</h3>
<Badge :variant="approvalStatusVariant(product.status)">
{{ product.status_label }}
</Badge>
<Badge
v-if="product.pending_action === 'delete'"
variant="destructive"
>
{{ product.pending_action_label }}
</Badge>
</div>
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
<Badge
v-for="category in product.categories"
:key="category.id"
variant="outline"
>
{{ category.name }}
</Badge>
</div>
<p
v-if="product.rejection_reason"
class="text-destructive text-sm"
>
Alasan penolakan: {{ product.rejection_reason }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<ProductStatusToggle :product="product" />
<DataTableActions :product="product" />
<DataTableActions
:product="product"
@reject="emit('reject', $event)"
/>
</div>
</div>
@ -137,8 +182,10 @@ function rowNumber(index: number): number {
<TableCell>
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
<div v-for="type in PRICE_TYPES" :key="type">
<div v-if="variant.prices.find((item) => item.type === type)"
class="flex items-center justify-between gap-3">
<div
v-if="variant.prices.find((item) => item.type === type)"
class="flex items-center justify-between gap-3"
>
<span class="text-muted-foreground">
{{ PRICE_TYPE_LABELS[type] }}
</span>
@ -174,10 +221,18 @@ function rowNumber(index: number): number {
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>

View File

@ -0,0 +1,101 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { ProductListItem } from '@/types/product';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
product?: ProductListItem | null;
}>();
const form = useForm({
reason: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
watch(open, (isOpen) => {
if (!isOpen) {
resetForm();
}
});
function submit() {
if (!props.product) {
return;
}
form.post(`/admin/master/products/${props.product.id}/reject`, {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menolak produk. Periksa kembali formulir.');
},
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tolak Pengajuan Produk</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="reject-product-reason" required>Alasan Penolakan</FieldLabel>
<Textarea
id="reject-product-reason"
v-model="form.reason"
placeholder="Contoh: Data varian belum lengkap"
rows="3"
autofocus
:maxlength="FIELD_LIMITS.reason"
/>
<FieldError :errors="formErrors(form, 'reason')" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" variant="destructive" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { Check, Pencil, Trash2, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
@ -13,31 +13,95 @@ const props = defineProps<{
product: ProductListItem;
}>();
const emit = defineEmits<{
reject: [product: ProductListItem];
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const approveConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const approveProcessing = ref(false);
const canEdit = computed(() => (
props.product.is_editable
&& !can('products.verify')
&& can('products.update')
));
const canDelete = computed(() => {
if (!can('products.delete')) {
return false;
}
if (can('products.verify')) {
return true;
}
return props.product.is_editable || props.product.status === 'approved';
});
const isDeleteRequest = computed(() => (
props.product.status === 'pending'
&& props.product.pending_action === 'delete'
));
const deleteDescription = computed(() => {
if (can('products.verify')) {
return `Produk ${props.product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`;
}
if (isDeleteRequest.value) {
return `Pengajuan penghapusan produk ${props.product.name} sedang menunggu persetujuan owner.`;
}
return `Pengajuan penghapusan produk ${props.product.name} akan dikirim ke owner untuk disetujui.`;
});
function destroyProduct() {
if (isDeleteRequest.value && !can('products.verify')) {
deleteConfirmOpen.value = false;
return;
}
deleteProcessing.value = true;
router.delete(`/admin/master/products/${props.product.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus produk.');
toast.error('Gagal mengajukan penghapusan produk.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
function approveProduct() {
approveProcessing.value = true;
router.post(`/admin/master/products/${props.product.id}/approve`, {}, {
preserveScroll: true,
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menyetujui produk.');
},
onFinish: () => {
approveProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('products.update')">
<Tooltip v-if="canEdit">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/master/products/${product.id}/edit`">
@ -49,7 +113,37 @@ function destroyProduct() {
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('products.delete')">
<Tooltip v-if="product.can_verify && can('products.verify')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 text-green-600 hover:text-green-600"
@click="approveConfirmOpen = true"
>
<Check class="size-4" />
<span class="sr-only">Setujui</span>
</Button>
</TooltipTrigger>
<TooltipContent>Setujui</TooltipContent>
</Tooltip>
<Tooltip v-if="product.can_verify && can('products.verify')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="emit('reject', product)"
>
<X class="size-4" />
<span class="sr-only">Tolak</span>
</Button>
</TooltipTrigger>
<TooltipContent>Tolak</TooltipContent>
</Tooltip>
<Tooltip v-if="canDelete && !isDeleteRequest">
<TooltipTrigger as-child>
<Button
variant="ghost"
@ -61,16 +155,27 @@ function destroyProduct() {
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
<TooltipContent>{{ can('products.verify') ? 'Hapus' : 'Ajukan Penghapusan' }}</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog
v-if="can('products.delete')"
v-if="can('products.verify')"
v-model:open="approveConfirmOpen"
title="Setujui produk?"
:description="`Produk ${product.name}${product.pending_action === 'delete' ? ' akan dihapus' : ' akan disetujui dan diaktifkan'}.`"
confirm-label="Setujui"
cancel-label="Batal"
:loading="approveProcessing"
@confirm="approveProduct"
/>
<ConfirmDialog
v-if="canDelete && !isDeleteRequest"
v-model:open="deleteConfirmOpen"
title="Hapus produk?"
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
:title="can('products.verify') ? 'Hapus produk?' : 'Ajukan penghapusan produk?'"
:description="deleteDescription"
:confirm-label="can('products.verify') ? 'Hapus' : 'Ajukan'"
cancel-label="Batal"
destructive
:loading="deleteProcessing"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@ -16,6 +16,13 @@ const { can } = useCan();
const isActive = ref(props.product.is_active);
const processing = ref(false);
const canToggle = computed(() => (
can('products.toggle-status')
&& can('products.verify')
&& props.product.status === 'approved'
&& props.product.pending_action !== 'delete'
));
watch(
() => props.product.is_active,
(value) => {
@ -24,7 +31,7 @@ watch(
);
function toggleStatus(checked: boolean) {
if (!can('products.toggle-status')) {
if (!canToggle.value) {
return;
}
@ -51,7 +58,7 @@ function toggleStatus(checked: boolean) {
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('products.toggle-status')"
:disabled="processing || !canToggle"
@update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">

View File

@ -4,6 +4,7 @@ import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
import ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.vue';
import RejectProductModal from '@/components/admin/master/products/RejectProductModal.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
@ -16,6 +17,7 @@ import type { DataTableFilterDef } from '@/types/data-table';
import type {
CategoryOption,
PaginatedProducts,
ProductListItem,
ProductOutOfStockGroup,
ProductStockWarningGroup,
} from '@/types/product';
@ -31,9 +33,13 @@ const props = defineProps<{
direction?: 'asc' | 'desc';
is_active?: string;
category_id?: string;
status?: string;
};
}>();
const rejectModalOpen = ref(false);
const rejectingProduct = ref<ProductListItem | null>(null);
const { can } = useCan();
const search = ref(props.filters.search ?? '');
@ -41,7 +47,7 @@ const { query, setSearch, setFilter, resetFilters, syncFromServer } =
useDataTableQuery({
url: '/admin/master/products',
initial: { ...props.filters },
filterKeys: ['is_active', 'category_id'],
filterKeys: ['is_active', 'category_id', 'status'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
@ -53,6 +59,16 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
type: 'select',
options: props.categories.map((c) => ({ value: String(c.value), label: c.label })),
},
{
key: 'status',
label: 'Persetujuan',
type: 'select',
options: [
{ value: 'pending', label: 'Menunggu' },
{ value: 'approved', label: 'Disetujui' },
{ value: 'rejected', label: 'Ditolak' },
],
},
{
key: 'is_active',
label: 'Status',
@ -66,9 +82,15 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
const filterValues = computed(() => ({
category_id: query.value.category_id ?? '',
status: query.value.status ?? '',
is_active: query.value.is_active ?? '',
}));
function openRejectModal(product: ProductListItem) {
rejectingProduct.value = product;
rejectModalOpen.value = true;
}
const tablePagination = computed(() => ({
currentPage: props.products.current_page,
perPage: props.products.per_page,
@ -144,8 +166,14 @@ watch(
:filter-values="filterValues"
@filter-change="setFilter"
@filters-reset="resetFilters"
@reject="openRejectModal"
/>
</CardContent>
</Card>
<RejectProductModal
v-model:open="rejectModalOpen"
:product="rejectingProduct"
/>
</AdminLayout>
</template>

View File

@ -42,6 +42,13 @@ export type ProductListItem = {
id: number;
name: string;
is_active: boolean;
status: 'pending' | 'approved' | 'rejected';
status_label: string;
pending_action?: 'delete' | null;
pending_action_label?: string | null;
rejection_reason?: string | null;
is_editable: boolean;
can_verify: boolean;
categories: ProductCategoryItem[];
variants: ProductVariantItem[];
};
@ -70,6 +77,7 @@ export type ProductFilters = {
sort?: string;
direction?: 'asc' | 'desc' | null;
is_active?: string;
status?: string;
};
export type PaginatedProducts = {

View File

@ -92,6 +92,14 @@
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
->name('toggle-status');
Route::post('{product}/approve', [ProductController::class, 'approve'])
->middleware('permission:'.Permission::PRODUCTS_VERIFY->value)
->name('approve');
Route::post('{product}/reject', [ProductController::class, 'reject'])
->middleware('permission:'.Permission::PRODUCTS_VERIFY->value)
->name('reject');
Route::delete('{product}', [ProductController::class, 'destroy'])
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
->name('destroy');