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_UPDATE = 'products.update';
|
||||||
case PRODUCTS_DELETE = 'products.delete';
|
case PRODUCTS_DELETE = 'products.delete';
|
||||||
case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status';
|
case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status';
|
||||||
|
case PRODUCTS_VERIFY = 'products.verify';
|
||||||
|
|
||||||
case RAW_MATERIALS_VIEW = 'raw-materials.view';
|
case RAW_MATERIALS_VIEW = 'raw-materials.view';
|
||||||
case RAW_MATERIALS_CREATE = 'raw-materials.create';
|
case RAW_MATERIALS_CREATE = 'raw-materials.create';
|
||||||
@ -150,6 +151,7 @@ public function label(): string
|
|||||||
self::PRODUCTS_UPDATE => 'Ubah Produk',
|
self::PRODUCTS_UPDATE => 'Ubah Produk',
|
||||||
self::PRODUCTS_DELETE => 'Hapus Produk',
|
self::PRODUCTS_DELETE => 'Hapus Produk',
|
||||||
self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk',
|
self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk',
|
||||||
|
self::PRODUCTS_VERIFY => 'Setujui/Tolak Produk',
|
||||||
|
|
||||||
self::RAW_MATERIALS_VIEW => 'Lihat Bahan Baku',
|
self::RAW_MATERIALS_VIEW => 'Lihat Bahan Baku',
|
||||||
self::RAW_MATERIALS_CREATE => 'Tambah 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_VIEW, self::CUSTOMERS_CREATE, self::CUSTOMERS_UPDATE,
|
||||||
self::CUSTOMERS_DELETE => 'Pelanggan',
|
self::CUSTOMERS_DELETE => 'Pelanggan',
|
||||||
self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE,
|
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_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,
|
||||||
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
|
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
|
||||||
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
|
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;
|
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\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Master\ProductRequest;
|
use App\Http\Requests\Admin\Master\ProductRequest;
|
||||||
|
use App\Http\Requests\Admin\Master\RejectProductRequest;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
@ -29,15 +32,17 @@ public function index(Request $request): Response
|
|||||||
$tableQuery = $this->parseDataTableQuery($request);
|
$tableQuery = $this->parseDataTableQuery($request);
|
||||||
$isActive = $request->string('is_active')->toString();
|
$isActive = $request->string('is_active')->toString();
|
||||||
$categoryId = $request->string('category_id')->toString();
|
$categoryId = $request->string('category_id')->toString();
|
||||||
|
$status = $request->string('status')->toString();
|
||||||
|
|
||||||
return Inertia::render('admin/master/products/Index', [
|
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(),
|
'outOfStockGroups' => $this->productService->outOfStockGroups(),
|
||||||
'stockWarningGroups' => $this->productService->stockWarningGroups(),
|
'stockWarningGroups' => $this->productService->stockWarningGroups(),
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->productService->categoryOptions(),
|
||||||
'filters' => $this->dataTableFilters($tableQuery, [
|
'filters' => $this->dataTableFilters($tableQuery, [
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
'category_id' => $categoryId,
|
'category_id' => $categoryId,
|
||||||
|
'status' => $status,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -51,9 +56,17 @@ public function create(): Response
|
|||||||
|
|
||||||
public function store(ProductRequest $request): RedirectResponse
|
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');
|
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
|
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');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
@ -92,7 +113,7 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon
|
|||||||
'is_active' => ['required', 'boolean'],
|
'is_active' => ['required', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->productService->toggleStatus($product, $validated['is_active']);
|
$this->productService->toggleStatus($product, $validated['is_active'], $request->user());
|
||||||
|
|
||||||
$this->flashStatusUpdated('produk');
|
$this->flashStatusUpdated('produk');
|
||||||
|
|
||||||
@ -101,9 +122,56 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon
|
|||||||
|
|
||||||
public function destroy(Product $product): RedirectResponse
|
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');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\SystemConfiguration;
|
use App\Models\SystemConfiguration;
|
||||||
@ -23,6 +24,7 @@ public function index(): Response
|
|||||||
$categories = Category::whereHas('products')->get(['id', 'name', 'slug']);
|
$categories = Category::whereHas('products')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
$products = Product::where('is_active', true)
|
$products = Product::where('is_active', true)
|
||||||
|
->where('status', ProductStatus::APPROVED)
|
||||||
->with([
|
->with([
|
||||||
'categories',
|
'categories',
|
||||||
'variants' => fn ($query) => $query
|
'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;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ProductPendingAction;
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
|
use App\Models\Concerns\HasRejection;
|
||||||
use App\Models\Concerns\InteractsWithActivityLog;
|
use App\Models\Concerns\InteractsWithActivityLog;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@ -13,9 +19,17 @@
|
|||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Sluggable(from: 'name', to: 'slug')]
|
#[Sluggable(from: 'name', to: 'slug')]
|
||||||
|
#[Appends([
|
||||||
|
'status_label',
|
||||||
|
'pending_action_label',
|
||||||
|
'rejection_reason',
|
||||||
|
'is_editable',
|
||||||
|
'can_verify',
|
||||||
|
])]
|
||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasRejection;
|
||||||
use InteractsWithActivityLog;
|
use InteractsWithActivityLog;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
@ -23,6 +37,10 @@ protected function casts(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'is_active' => 'boolean',
|
'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);
|
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;
|
namespace App\Services\Manage;
|
||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\CuttingMaterial;
|
use App\Models\CuttingMaterial;
|
||||||
use App\Models\CuttingResult;
|
use App\Models\CuttingResult;
|
||||||
@ -181,7 +182,8 @@ public function productCatalog(?Cutting $cutting = null): Collection
|
|||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->where(function (Builder $query) use ($selectedVariantIds): void {
|
->where(function (Builder $query) use ($selectedVariantIds): void {
|
||||||
$query->where('is_active', true);
|
$query->where('is_active', true)
|
||||||
|
->where('status', ProductStatus::APPROVED);
|
||||||
|
|
||||||
if ($selectedVariantIds !== []) {
|
if ($selectedVariantIds !== []) {
|
||||||
$query->orWhereHas(
|
$query->orWhereHas(
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Enums\OrderStatus;
|
use App\Enums\OrderStatus;
|
||||||
use App\Enums\PaymentType;
|
use App\Enums\PaymentType;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Enums\ProductStockQuality;
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
@ -143,7 +144,8 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti
|
|||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->where(function (Builder $query) use ($orderVariantIds): void {
|
->where(function (Builder $query) use ($orderVariantIds): void {
|
||||||
$query->where('is_active', true);
|
$query->where('is_active', true)
|
||||||
|
->where('status', ProductStatus::APPROVED);
|
||||||
|
|
||||||
if ($orderVariantIds !== []) {
|
if ($orderVariantIds !== []) {
|
||||||
$query->orWhereHas(
|
$query->orWhereHas(
|
||||||
|
|||||||
@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Services\Master;
|
namespace App\Services\Master;
|
||||||
|
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProductPendingAction;
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Manage\CuttingResultPriceResolver;
|
use App\Services\Manage\CuttingResultPriceResolver;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProductService
|
class ProductService
|
||||||
{
|
{
|
||||||
@ -19,6 +26,7 @@ class ProductService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,7 +90,9 @@ private function inventoryAlertGroups(callable $filter): array
|
|||||||
{
|
{
|
||||||
return ProductVariant::query()
|
return ProductVariant::query()
|
||||||
->with(['product', 'media'])
|
->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()
|
->get()
|
||||||
->filter($filter)
|
->filter($filter)
|
||||||
->sortBy([
|
->sortBy([
|
||||||
@ -114,11 +124,12 @@ private function inventoryAlertGroups(callable $filter): array
|
|||||||
/**
|
/**
|
||||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
* @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()
|
$query = Product::query()
|
||||||
->with([
|
->with([
|
||||||
'categories',
|
'categories',
|
||||||
|
'rejection',
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with('media')
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
@ -130,13 +141,18 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
->orWhere('slug', 'like', "%{$search}%")
|
->orWhere('slug', 'like', "%{$search}%")
|
||||||
->orWhere('description', 'like', "%{$search}%")
|
->orWhere('description', 'like', "%{$search}%")
|
||||||
->orWhereHas('categories', fn (Builder $query) => $query->where('name', '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(
|
->when(
|
||||||
$isActive !== '',
|
$isActive !== '',
|
||||||
fn (Builder $query) => $query->where('is_active', $isActive === '1')
|
fn (Builder $query) => $query->where('is_active', $isActive === '1')
|
||||||
)
|
)
|
||||||
|
->when(
|
||||||
|
$status !== '',
|
||||||
|
fn (Builder $query) => $query->where('status', $status)
|
||||||
|
)
|
||||||
->when(
|
->when(
|
||||||
$categoryId !== '',
|
$categoryId !== '',
|
||||||
fn (Builder $query) => $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $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
|
* @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([
|
$product = Product::create([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'description' => $validated['description'] ?? null,
|
'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']);
|
$product->categories()->sync($validated['category_ids']);
|
||||||
@ -195,17 +218,43 @@ public function create(array $validated): void
|
|||||||
foreach ($validated['variants'] as $index => $variantData) {
|
foreach ($validated['variants'] as $index => $variantData) {
|
||||||
$this->createVariant($product, $variantData, $index);
|
$this->createVariant($product, $variantData, $index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $autoApprove) {
|
||||||
|
$this->notifyOwnersOfPendingProduct($product, 'baru');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @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->name = $validated['name'];
|
||||||
$product->description = $validated['description'] ?? null;
|
$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->save();
|
||||||
|
|
||||||
$product->categories()->sync($validated['category_ids']);
|
$product->categories()->sync($validated['category_ids']);
|
||||||
@ -237,16 +286,185 @@ public function update(Product $product, array $validated): void
|
|||||||
|
|
||||||
$this->createVariant($product, $variantData, $index);
|
$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->is_active = $isActive;
|
||||||
$product->save();
|
$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 {
|
DB::transaction(function () use ($product): void {
|
||||||
$product->variants()->each(function (ProductVariant $variant): void {
|
$product->variants()->each(function (ProductVariant $variant): void {
|
||||||
@ -254,13 +472,56 @@ public function delete(Product $product): void
|
|||||||
});
|
});
|
||||||
$product->variants()->delete();
|
$product->variants()->delete();
|
||||||
$product->categories()->detach();
|
$product->categories()->detach();
|
||||||
|
$product->rejection()?->delete();
|
||||||
$product->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
|
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);
|
$query->orderBy($sort, $direction);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -20,6 +21,8 @@ public function definition(): array
|
|||||||
'slug' => Str::slug($name),
|
'slug' => Str::slug($name),
|
||||||
'description' => fake()->optional()->paragraph(),
|
'description' => fake()->optional()->paragraph(),
|
||||||
'is_active' => true,
|
'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;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\ProductStatus;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
@ -77,6 +78,8 @@ public function run(): void
|
|||||||
'slug' => str()->slug($productData['name']),
|
'slug' => str()->slug($productData['name']),
|
||||||
'description' => $productData['description'],
|
'description' => $productData['description'],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'status' => ProductStatus::APPROVED,
|
||||||
|
'was_ever_approved' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->categories()->sync(
|
$product->categories()->sync(
|
||||||
|
|||||||
@ -28,10 +28,9 @@ import type {
|
|||||||
} from '@/types/data-table';
|
} from '@/types/data-table';
|
||||||
import {
|
import {
|
||||||
PRICE_TYPES,
|
PRICE_TYPES,
|
||||||
PRICE_TYPE_LABELS
|
PRICE_TYPE_LABELS,
|
||||||
|
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
import type {ProductListItem} from '@/types/product';
|
import type { ProductListItem } from '@/types/product';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
products: ProductListItem[];
|
products: ProductListItem[];
|
||||||
@ -47,6 +46,7 @@ const search = defineModel<string>('search', { default: '' });
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'filter-change': [key: string, value: string];
|
'filter-change': [key: string, value: string];
|
||||||
'filters-reset': [];
|
'filters-reset': [];
|
||||||
|
reject: [product: ProductListItem];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.products.length);
|
const showingCount = computed(() => props.products.length);
|
||||||
@ -72,36 +72,81 @@ function formatStock(value: number): string {
|
|||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
|
<DataTableToolbar
|
||||||
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
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-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
|
<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">
|
<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">
|
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
|
||||||
{{ rowNumber(index) }}
|
{{ rowNumber(index) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 space-y-2">
|
<div class="min-w-0 space-y-2">
|
||||||
<h3 class="font-medium leading-tight">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{{ product.name }}
|
<h3 class="font-medium leading-tight">
|
||||||
</h3>
|
{{ 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">
|
<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 }}
|
{{ category.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="product.rejection_reason"
|
||||||
|
class="text-destructive text-sm"
|
||||||
|
>
|
||||||
|
Alasan penolakan: {{ product.rejection_reason }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||||
<ProductStatusToggle :product="product" />
|
<ProductStatusToggle :product="product" />
|
||||||
<DataTableActions :product="product" />
|
<DataTableActions
|
||||||
|
:product="product"
|
||||||
|
@reject="emit('reject', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -137,8 +182,10 @@ function rowNumber(index: number): number {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
|
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
|
||||||
<div v-for="type in PRICE_TYPES" :key="type">
|
<div v-for="type in PRICE_TYPES" :key="type">
|
||||||
<div v-if="variant.prices.find((item) => item.type === type)"
|
<div
|
||||||
class="flex items-center justify-between gap-3">
|
v-if="variant.prices.find((item) => item.type === type)"
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
</span>
|
</span>
|
||||||
@ -174,10 +221,18 @@ function rowNumber(index: number): number {
|
|||||||
{{ paginationSummary }}
|
{{ paginationSummary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
|
<div
|
||||||
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
|
v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||||
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
|
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
|
||||||
:disabled="!link.url || link.active" as-child>
|
>
|
||||||
|
<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>
|
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
||||||
<span v-html="link.label" />
|
<span v-html="link.label" />
|
||||||
</Link>
|
</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">
|
<script setup lang="ts">
|
||||||
import { Link, router } from '@inertiajs/vue3';
|
import { Link, router } from '@inertiajs/vue3';
|
||||||
import { Pencil, Trash2 } from '@lucide/vue';
|
import { Check, Pencil, Trash2, X } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -13,31 +13,95 @@ const props = defineProps<{
|
|||||||
product: ProductListItem;
|
product: ProductListItem;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reject: [product: ProductListItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
|
|
||||||
const deleteConfirmOpen = ref(false);
|
const deleteConfirmOpen = ref(false);
|
||||||
|
const approveConfirmOpen = ref(false);
|
||||||
const deleteProcessing = 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() {
|
function destroyProduct() {
|
||||||
|
if (isDeleteRequest.value && !can('products.verify')) {
|
||||||
|
deleteConfirmOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
deleteProcessing.value = true;
|
deleteProcessing.value = true;
|
||||||
|
|
||||||
router.delete(`/admin/master/products/${props.product.id}`, {
|
router.delete(`/admin/master/products/${props.product.id}`, {
|
||||||
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
deleteConfirmOpen.value = false;
|
deleteConfirmOpen.value = false;
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Gagal menghapus produk.');
|
toast.error('Gagal mengajukan penghapusan produk.');
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
deleteProcessing.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<Tooltip v-if="can('products.update')">
|
<Tooltip v-if="canEdit">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||||
<Link :href="`/admin/master/products/${product.id}/edit`">
|
<Link :href="`/admin/master/products/${product.id}/edit`">
|
||||||
@ -49,7 +113,37 @@ function destroyProduct() {
|
|||||||
<TooltipContent>Ubah</TooltipContent>
|
<TooltipContent>Ubah</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -61,16 +155,27 @@ function destroyProduct() {
|
|||||||
<span class="sr-only">Hapus</span>
|
<span class="sr-only">Hapus</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Hapus</TooltipContent>
|
<TooltipContent>{{ can('products.verify') ? 'Hapus' : 'Ajukan Penghapusan' }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<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"
|
v-model:open="deleteConfirmOpen"
|
||||||
title="Hapus produk?"
|
:title="can('products.verify') ? 'Hapus produk?' : 'Ajukan penghapusan produk?'"
|
||||||
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
|
:description="deleteDescription"
|
||||||
confirm-label="Hapus"
|
:confirm-label="can('products.verify') ? 'Hapus' : 'Ajukan'"
|
||||||
cancel-label="Batal"
|
cancel-label="Batal"
|
||||||
destructive
|
destructive
|
||||||
:loading="deleteProcessing"
|
:loading="deleteProcessing"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@ -16,6 +16,13 @@ const { can } = useCan();
|
|||||||
const isActive = ref(props.product.is_active);
|
const isActive = ref(props.product.is_active);
|
||||||
const processing = ref(false);
|
const processing = ref(false);
|
||||||
|
|
||||||
|
const canToggle = computed(() => (
|
||||||
|
can('products.toggle-status')
|
||||||
|
&& can('products.verify')
|
||||||
|
&& props.product.status === 'approved'
|
||||||
|
&& props.product.pending_action !== 'delete'
|
||||||
|
));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.product.is_active,
|
() => props.product.is_active,
|
||||||
(value) => {
|
(value) => {
|
||||||
@ -24,7 +31,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toggleStatus(checked: boolean) {
|
function toggleStatus(checked: boolean) {
|
||||||
if (!can('products.toggle-status')) {
|
if (!canToggle.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +58,7 @@ function toggleStatus(checked: boolean) {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="isActive"
|
:model-value="isActive"
|
||||||
:disabled="processing || !can('products.toggle-status')"
|
:disabled="processing || !canToggle"
|
||||||
@update:model-value="toggleStatus"
|
@update:model-value="toggleStatus"
|
||||||
/>
|
/>
|
||||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Plus } from '@lucide/vue';
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
|
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
|
||||||
import ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -16,6 +17,7 @@ import type { DataTableFilterDef } from '@/types/data-table';
|
|||||||
import type {
|
import type {
|
||||||
CategoryOption,
|
CategoryOption,
|
||||||
PaginatedProducts,
|
PaginatedProducts,
|
||||||
|
ProductListItem,
|
||||||
ProductOutOfStockGroup,
|
ProductOutOfStockGroup,
|
||||||
ProductStockWarningGroup,
|
ProductStockWarningGroup,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
@ -31,9 +33,13 @@ const props = defineProps<{
|
|||||||
direction?: 'asc' | 'desc';
|
direction?: 'asc' | 'desc';
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
|
status?: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const rejectModalOpen = ref(false);
|
||||||
|
const rejectingProduct = ref<ProductListItem | null>(null);
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const search = ref(props.filters.search ?? '');
|
const search = ref(props.filters.search ?? '');
|
||||||
|
|
||||||
@ -41,7 +47,7 @@ const { query, setSearch, setFilter, resetFilters, syncFromServer } =
|
|||||||
useDataTableQuery({
|
useDataTableQuery({
|
||||||
url: '/admin/master/products',
|
url: '/admin/master/products',
|
||||||
initial: { ...props.filters },
|
initial: { ...props.filters },
|
||||||
filterKeys: ['is_active', 'category_id'],
|
filterKeys: ['is_active', 'category_id', 'status'],
|
||||||
});
|
});
|
||||||
|
|
||||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||||
@ -53,6 +59,16 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: props.categories.map((c) => ({ value: String(c.value), label: c.label })),
|
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',
|
key: 'is_active',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@ -66,9 +82,15 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
|
|||||||
|
|
||||||
const filterValues = computed(() => ({
|
const filterValues = computed(() => ({
|
||||||
category_id: query.value.category_id ?? '',
|
category_id: query.value.category_id ?? '',
|
||||||
|
status: query.value.status ?? '',
|
||||||
is_active: query.value.is_active ?? '',
|
is_active: query.value.is_active ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function openRejectModal(product: ProductListItem) {
|
||||||
|
rejectingProduct.value = product;
|
||||||
|
rejectModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const tablePagination = computed(() => ({
|
const tablePagination = computed(() => ({
|
||||||
currentPage: props.products.current_page,
|
currentPage: props.products.current_page,
|
||||||
perPage: props.products.per_page,
|
perPage: props.products.per_page,
|
||||||
@ -144,8 +166,14 @@ watch(
|
|||||||
:filter-values="filterValues"
|
:filter-values="filterValues"
|
||||||
@filter-change="setFilter"
|
@filter-change="setFilter"
|
||||||
@filters-reset="resetFilters"
|
@filters-reset="resetFilters"
|
||||||
|
@reject="openRejectModal"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<RejectProductModal
|
||||||
|
v-model:open="rejectModalOpen"
|
||||||
|
:product="rejectingProduct"
|
||||||
|
/>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -42,6 +42,13 @@ export type ProductListItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
is_active: boolean;
|
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[];
|
categories: ProductCategoryItem[];
|
||||||
variants: ProductVariantItem[];
|
variants: ProductVariantItem[];
|
||||||
};
|
};
|
||||||
@ -70,6 +77,7 @@ export type ProductFilters = {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
direction?: 'asc' | 'desc' | null;
|
direction?: 'asc' | 'desc' | null;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedProducts = {
|
export type PaginatedProducts = {
|
||||||
|
|||||||
@ -92,6 +92,14 @@
|
|||||||
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
||||||
->name('toggle-status');
|
->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'])
|
Route::delete('{product}', [ProductController::class, 'destroy'])
|
||||||
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user