feat: implement product approval workflow by adding verification, rejection, and approval functionalities, including UI updates and permission checks
This commit is contained in:
parent
2a2cc0fb96
commit
5dc24b1494
@ -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,
|
||||
|
||||
15
app/Enums/ProductPendingAction.php
Normal file
15
app/Enums/ProductPendingAction.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
23
app/Enums/ProductStatus.php
Normal file
23
app/Enums/ProductStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
34
app/Http/Requests/Admin/Master/RejectProductRequest.php
Normal file
34
app/Http/Requests/Admin/Master/RejectProductRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
@ -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'">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user