Refactor product status management: replace is_active with status enum
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

- Updated the OwnerVerificationRequest model to change pendingToggleIsActive to pendingToggleStatus, reflecting the new status structure.
- Refactored the Product model to replace is_active with status, utilizing the ProductStatus enum for better clarity and type safety.
- Modified the ProductService to accommodate the new status field, including pagination and creation logic.
- Adjusted the AnalysisService to filter products based on their status.
- Updated the VerificationChangeFormatter to handle the new status field.
- Created a migration to remove is_active from the products table and add status with appropriate default values.
- Refactored the ProductSeeder to use the new status field.
- Updated frontend components and types to reflect the changes from is_active to status.
- Adjusted tests to ensure they validate the new status logic correctly.
This commit is contained in:
Yoga Pangestu 2026-07-18 16:34:34 +07:00
parent f337e50fa2
commit cf5b300895
21 changed files with 305 additions and 120 deletions

View File

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum ProductStatus: string
{
use ProvidesEnumOptions;
case DRAFT = 'draft';
case ACTIVE = 'active';
case INACTIVE = 'inactive';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draf',
self::ACTIVE => 'Aktif',
self::INACTIVE => 'Nonaktif',
};
}
}

View File

@ -31,15 +31,15 @@ public function index(Request $request): Response
{ {
$tableQuery = $this->parseDataTableQuery($request); $tableQuery = $this->parseDataTableQuery($request);
$tableQuery['search_id'] = $request->string('search_id')->trim()->toString(); $tableQuery['search_id'] = $request->string('search_id')->trim()->toString();
$isActive = $request->string('is_active')->toString(); $status = $request->string('status')->toString();
$categoryId = $request->string('category_id')->toString(); $categoryId = $request->string('category_id')->toString();
$stockStatus = $request->string('stock_status')->toString(); $stockStatus = $request->string('stock_status')->toString();
return Inertia::render('admin/master/products/Index', [ return Inertia::render('admin/master/products/Index', [
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus), 'products' => $this->productService->paginateForIndex($tableQuery, $status, $categoryId, $stockStatus),
'categories' => $this->categoryService->getSelectOptions(), 'categories' => $this->categoryService->getSelectOptions(),
'filters' => $this->dataTableFilters($tableQuery, [ 'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive, 'status' => $status,
'category_id' => $categoryId, 'category_id' => $categoryId,
'stock_status' => $stockStatus, 'stock_status' => $stockStatus,
'search_id' => $tableQuery['search_id'], 'search_id' => $tableQuery['search_id'],

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Master; namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\ProductStatus;
use App\Http\Requests\Concerns\HasProductVariantRules; use App\Http\Requests\Concerns\HasProductVariantRules;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -32,6 +33,8 @@ public function rules(): array
'category_ids' => ['required', 'array', 'min:1'], 'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')], 'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
'status' => ['required', 'string', Rule::in(ProductStatus::values())],
...$this->productVariantRules( ...$this->productVariantRules(
productId: $this->route('product')?->id, productId: $this->route('product')?->id,
imagesRequired: $this->isMethod('POST'), imagesRequired: $this->isMethod('POST'),

View File

@ -2,7 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use App\Enums\ProductStatus;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ToggleStatusRequest extends FormRequest class ToggleStatusRequest extends FormRequest
{ {
@ -17,7 +19,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'is_active' => ['required', 'boolean'], 'status' => ['required', 'string', Rule::in([ProductStatus::ACTIVE->value, ProductStatus::INACTIVE->value])],
]; ];
} }
@ -27,7 +29,7 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'is_active' => 'aktif', 'status' => 'status',
]; ];
} }
} }

View File

@ -150,7 +150,7 @@ public static function mediaModuleName(): string
return 'owner_verification_request'; return 'owner_verification_request';
} }
public function pendingToggleIsActive(): ?bool public function pendingToggleStatus(): ?string
{ {
if ($this->action !== OwnerVerificationAction::TOGGLE_STATUS) { if ($this->action !== OwnerVerificationAction::TOGGLE_STATUS) {
return null; return null;
@ -159,11 +159,11 @@ public function pendingToggleIsActive(): ?bool
$payload = is_array($this->payload) ? $this->payload : []; $payload = is_array($this->payload) ? $this->payload : [];
$new = $payload['new'] ?? null; $new = $payload['new'] ?? null;
if (! is_array($new) || ! array_key_exists('is_active', $new)) { if (! is_array($new) || ! array_key_exists('status', $new)) {
return null; return null;
} }
return (bool) $new['is_active']; return $new['status'];
} }
public function priceImageCollection(int $index): string public function priceImageCollection(int $index): string

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\OwnerVerificationStatus; use App\Enums\OwnerVerificationStatus;
use App\Enums\ProductStatus;
use App\Models\Concerns\HasPendingOwnerVerification; use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
@ -36,22 +37,28 @@ class Product extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'is_active' => 'boolean', 'status' => ProductStatus::class,
]; ];
} }
// 3. Scope (grouped by column, then alphabetical) // 3. Scope (grouped by column, then alphabetical)
// Column Group: is_active // Column Group: status
#[Scope] #[Scope]
protected function active(Builder $query): void protected function active(Builder $query): void
{ {
$query->where('is_active', true); $query->where('status', ProductStatus::ACTIVE);
} }
#[Scope] #[Scope]
protected function inactive(Builder $query): void protected function inactive(Builder $query): void
{ {
$query->where('is_active', false); $query->where('status', ProductStatus::INACTIVE);
}
#[Scope]
protected function draft(Builder $query): void
{
$query->where('status', ProductStatus::DRAFT);
} }
// 4. Attribute // 4. Attribute

View File

@ -5,6 +5,7 @@
use App\Enums\OwnerVerificationAction; use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus; use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\ProductStatus;
use App\Models\Category; use App\Models\Category;
use App\Models\OwnerVerificationRequest; use App\Models\OwnerVerificationRequest;
use App\Models\Product; use App\Models\Product;
@ -30,7 +31,7 @@ public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = '', string $stockStatus = ''): LengthAwarePaginator public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = ''): LengthAwarePaginator
{ {
$query = Product::query() $query = Product::query()
->with([ ->with([
@ -50,7 +51,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$search = $tableQuery['search']; $search = $tableQuery['search'];
$query->whereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); $query->whereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
}) })
->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1')) ->when($status !== '', fn (Builder $query) => $query->where('status', $status))
->when($categoryId !== '', function (Builder $query) use ($categoryId): void { ->when($categoryId !== '', function (Builder $query) use ($categoryId): void {
$query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId)); $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId));
}) })
@ -93,8 +94,8 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$product->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); $product->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
$product->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username); $product->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username);
$product->setAttribute( $product->setAttribute(
'display_is_active', 'display_status',
$pendingRequest?->pendingToggleIsActive() ?? $product->is_active, $pendingRequest?->pendingToggleStatus() ?? $product->status->value,
); );
return $product; return $product;
@ -120,13 +121,14 @@ public function findForEdit(Product $product): Product
public function create(array $validated, User $user): void public function create(array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$isDraft = ($validated['status'] ?? '') === ProductStatus::DRAFT->value;
$product = $this->runInTransaction( $product = $this->runInTransaction(
function () use ($validated, $user, $isOwner): Product { function () use ($validated, $user, $isOwner, $isDraft): Product {
$product = Product::create([ $product = Product::create([
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'is_active' => $isOwner, 'status' => $isDraft ? ProductStatus::DRAFT : ($isOwner ? ProductStatus::ACTIVE : ProductStatus::INACTIVE),
]); ]);
$product->categories()->sync($validated['category_ids'] ?? []); $product->categories()->sync($validated['category_ids'] ?? []);
@ -150,7 +152,7 @@ function () use ($validated, $user, $isOwner): Product {
} }
} }
if (! $isOwner) { if (! $isOwner && ! $isDraft) {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE, 'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -169,6 +171,16 @@ function () use ($validated, $user, $isOwner): Product {
'Gagal membuat produk', 'Gagal membuat produk',
); );
if ($isDraft) {
$this->notifyOwner(
'Simpan Draft Produk',
"Produk '{$validated['name']}' disimpan sebagai draft oleh {$user->profile?->full_name}.",
route('admin.master.products.index'),
);
return;
}
if ($isOwner) { if ($isOwner) {
$this->cacheForgetByPattern('master:products:*'); $this->cacheForgetByPattern('master:products:*');
@ -193,10 +205,14 @@ function () use ($validated, $user, $isOwner): Product {
public function update(Product $product, array $validated, User $user): void public function update(Product $product, array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$isDraft = $product->status === ProductStatus::DRAFT;
$changingToActive = ($validated['status'] ?? '') === ProductStatus::ACTIVE->value;
$canEditDirectly = $isOwner || ($isDraft && ! $changingToActive);
$this->runInTransaction( $this->runInTransaction(
function () use ($validated, $product, $user, $isOwner): void { function () use ($validated, $product, $user, $canEditDirectly): void {
if ($isOwner) {
if ($canEditDirectly) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload); $this->applyPayloadToProduct($product, $payload);
@ -253,6 +269,24 @@ function () use ($validated, $product, $user, $isOwner): void {
'Gagal memperbarui produk', 'Gagal memperbarui produk',
); );
if ($canEditDirectly) {
$this->cacheForgetByPattern('master:products:*');
$this->cacheForgetByPattern('prices:*');
$this->cacheForget('homepage:page_data');
$actionLabel = $isDraft && $changingToActive
? 'Publikasi Draft Produk'
: 'Ubah Produk';
$this->notifyOwner(
$actionLabel,
"Produk '{$product->name}' telah diperbarui oleh {$user->profile?->full_name}.",
route('admin.master.products.index', ['search_id' => $product->id]),
);
return;
}
$this->cacheForgetByPattern('master:products:*'); $this->cacheForgetByPattern('master:products:*');
$this->cacheForgetByPattern('prices:*'); $this->cacheForgetByPattern('prices:*');
$this->cacheForget('homepage:page_data'); $this->cacheForget('homepage:page_data');
@ -370,16 +404,17 @@ function () use ($product, $user): void {
public function toggleStatus(Product $product, array $validated, User $user): void public function toggleStatus(Product $product, array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$newStatus = ProductStatus::tryFrom($validated['status'] ?? '');
if ($isOwner) { if ($isOwner) {
$product->update([ $product->update([
'is_active' => (bool) $validated['is_active'], 'status' => $newStatus,
]); ]);
$this->cacheForgetByPattern('master:products:*'); $this->cacheForgetByPattern('master:products:*');
$this->cacheForget('homepage:page_data'); $this->cacheForget('homepage:page_data');
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $statusLabel = $newStatus?->label() ?? 'unknown';
$this->notifyOwner( $this->notifyOwner(
'Ubah Status Produk', 'Ubah Status Produk',
@ -391,7 +426,7 @@ public function toggleStatus(Product $product, array $validated, User $user): vo
} }
$this->runInTransaction( $this->runInTransaction(
function () use ($product, $validated, $user): void { function () use ($product, $user, $newStatus): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS, 'action' => OwnerVerificationAction::TOGGLE_STATUS,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -401,11 +436,11 @@ function () use ($product, $validated, $user): void {
'payload' => [ 'payload' => [
'old' => [ 'old' => [
'name' => $product->name, 'name' => $product->name,
'is_active' => $product->is_active, 'status' => $product->status->value,
], ],
'new' => [ 'new' => [
'name' => $product->name, 'name' => $product->name,
'is_active' => (bool) $validated['is_active'], 'status' => $newStatus->value,
], ],
], ],
]); ]);
@ -413,7 +448,7 @@ function () use ($product, $validated, $user): void {
'Gagal mengajukan perubahan status produk', 'Gagal mengajukan perubahan status produk',
); );
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $statusLabel = $newStatus?->label() ?? 'unknown';
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
$user, $user,
@ -478,7 +513,7 @@ public function applyCreate(OwnerVerificationRequest $verificationRequest): void
} }
$product->update([ $product->update([
'is_active' => true, 'status' => ProductStatus::ACTIVE,
]); ]);
} }
@ -505,10 +540,13 @@ private function applyPayloadToProduct(
'description' => $payload['description'] ?? null, 'description' => $payload['description'] ?? null,
]); ]);
if (array_key_exists('is_active', $payload)) { if (array_key_exists('status', $payload)) {
$product->update([ $status = ProductStatus::tryFrom($payload['status']);
'is_active' => (bool) $payload['is_active'], if ($status !== null) {
]); $product->update([
'status' => $status,
]);
}
} }
$product->categories()->sync($payload['category_ids'] ?? []); $product->categories()->sync($payload['category_ids'] ?? []);
@ -609,8 +647,10 @@ public function applyToggleStatus(OwnerVerificationRequest $verificationRequest)
$newPayload = $this->payloadNew($verificationRequest); $newPayload = $this->payloadNew($verificationRequest);
$status = ProductStatus::tryFrom($newPayload['status'] ?? '');
$product->update([ $product->update([
'is_active' => (bool) ($newPayload['is_active'] ?? false), 'status' => $status ?? ProductStatus::INACTIVE,
]); ]);
} }
@ -713,7 +753,7 @@ private function snapshotProduct(Product $product): array
'name' => $product->name, 'name' => $product->name,
'description' => $product->description, 'description' => $product->description,
'category_ids' => $product->categories->pluck('id')->all(), 'category_ids' => $product->categories->pluck('id')->all(),
'is_active' => $product->is_active, 'status' => $product->status->value,
'variants' => $product->variants 'variants' => $product->variants
->map(fn (ProductVariant $variant) => [ ->map(fn (ProductVariant $variant) => [
'id' => $variant->id, 'id' => $variant->id,
@ -744,6 +784,7 @@ private function buildPayloadFromValidated(array $validated): array
return [ return [
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'status' => $validated['status'] ?? ProductStatus::ACTIVE->value,
'category_ids' => $validated['category_ids'] ?? [], 'category_ids' => $validated['category_ids'] ?? [],
'variants' => collect($validated['variants'] ?? []) 'variants' => collect($validated['variants'] ?? [])
->map(fn (array $variantData) => [ ->map(fn (array $variantData) => [
@ -820,7 +861,7 @@ private function copyRequestVariantImages(OwnerVerificationRequest $verification
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', 'status'], true)) {
$query->orderBy($sort, $direction); $query->orderBy($sort, $direction);
return; return;

View File

@ -6,6 +6,7 @@
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
@ -646,6 +647,7 @@ public function getProductStock(): array
{ {
$variants = ProductVariant::query() $variants = ProductVariant::query()
->join('products', 'product_variants.product_id', '=', 'products.id') ->join('products', 'product_variants.product_id', '=', 'products.id')
->where('products.status', ProductStatus::ACTIVE)
->selectRaw(' ->selectRaw('
SUM(product_variants.stock) as total_stock, SUM(product_variants.stock) as total_stock,
SUM(product_variants.reject_stock) as total_reject, SUM(product_variants.reject_stock) as total_reject,
@ -657,6 +659,8 @@ public function getProductStock(): array
$totalValue = \DB::table('product_prices') $totalValue = \DB::table('product_prices')
->join('product_variants', 'product_prices.variant_id', '=', 'product_variants.id') ->join('product_variants', 'product_prices.variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
->where('products.status', ProductStatus::ACTIVE)
->where('product_prices.type', PriceType::HARGA_MODAL->value) ->where('product_prices.type', PriceType::HARGA_MODAL->value)
->whereNull('product_variants.deleted_at') ->whereNull('product_variants.deleted_at')
->whereNull('product_prices.deleted_at') ->whereNull('product_prices.deleted_at')
@ -664,8 +668,10 @@ public function getProductStock(): array
->value('total'); ->value('total');
$totalCategories = \DB::table('product_categories') $totalCategories = \DB::table('product_categories')
->distinct('category_id') ->join('products', 'product_categories.product_id', '=', 'products.id')
->count('category_id'); ->where('products.status', ProductStatus::ACTIVE)
->distinct('product_categories.category_id')
->count('product_categories.category_id');
return [ return [
'total_stock' => (int) ($variants->total_stock ?? 0), 'total_stock' => (int) ($variants->total_stock ?? 0),

View File

@ -3,6 +3,7 @@
namespace App\Support\OwnerVerification; namespace App\Support\OwnerVerification;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStatus;
class VerificationChangeFormatter class VerificationChangeFormatter
{ {
@ -74,7 +75,7 @@ private static function label(string $field): string
'has_photos' => 'Foto Bukti', 'has_photos' => 'Foto Bukti',
'unit' => 'Satuan', 'unit' => 'Satuan',
'unit_label' => 'Satuan', 'unit_label' => 'Satuan',
'is_active' => 'Status Aktif', 'status' => 'Status',
'variants' => 'Varian', 'variants' => 'Varian',
'prices' => 'Varian Harga', 'prices' => 'Varian Harga',
'quantity' => 'Perubahan Stok Ecer', 'quantity' => 'Perubahan Stok Ecer',
@ -107,8 +108,10 @@ private static function presentValue(string $field, mixed $value): mixed
return null; return null;
} }
if ($field === 'is_active') { if ($field === 'status') {
return (bool) $value; $status = ProductStatus::tryFrom((string) $value);
return $status?->label() ?? $value;
} }
$marketplaceKeys = [ $marketplaceKeys = [

View File

@ -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;
@ -19,14 +20,14 @@ public function definition(): array
'name' => Str::limit($name, 200, ''), 'name' => Str::limit($name, 200, ''),
'slug' => Str::slug($name), 'slug' => Str::slug($name),
'description' => fake()->optional()->paragraph(), 'description' => fake()->optional()->paragraph(),
'is_active' => true, 'status' => ProductStatus::ACTIVE,
]; ];
} }
public function inactive(): static public function inactive(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'is_active' => false, 'status' => ProductStatus::INACTIVE,
]); ]);
} }
} }

View File

@ -0,0 +1,38 @@
<?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::ACTIVE->value)->after('description');
});
DB::table('products')->where('is_active', true)->update(['status' => ProductStatus::ACTIVE->value]);
DB::table('products')->where('is_active', false)->update(['status' => ProductStatus::INACTIVE->value]);
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('description');
});
DB::table('products')->where('status', ProductStatus::ACTIVE->value)->update(['is_active' => true]);
DB::table('products')->whereIn('status', [ProductStatus::INACTIVE->value, ProductStatus::DRAFT->value])->update(['is_active' => false]);
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@ -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,7 +78,7 @@ public function run(): void
'name' => $productData['name'], 'name' => $productData['name'],
'slug' => str()->slug($productData['name']), 'slug' => str()->slug($productData['name']),
'description' => $productData['description'], 'description' => $productData['description'],
'is_active' => true, 'status' => ProductStatus::ACTIVE,
]); ]);
$product->categories()->sync( $product->categories()->sync(

View File

@ -1,3 +1,10 @@
export const ProductStatus = {
DRAFT: 'draft',
ACTIVE: 'active',
INACTIVE: 'inactive',
} as const;
/** @deprecated Use ProductStatus instead */
export const ActiveStatus = { export const ActiveStatus = {
ACTIVE: '1', ACTIVE: '1',
INACTIVE: '0', INACTIVE: '0',

View File

@ -80,6 +80,7 @@ const props = defineProps<{
productStock: { productStock: {
total_stock: number; total_stock: number;
total_reject: number; total_reject: number;
total_retail: number;
total_value: number; total_value: number;
total_products: number; total_products: number;
total_variants: number; total_variants: number;
@ -674,28 +675,29 @@ watch([startDate, endDate], () => {
]" /> ]" />
<StatCard v-if="can('analysis.product_stock')" title="Stok Produk" :icon="ShoppingCart" <StatCard v-if="can('analysis.product_stock')" title="Stok Produk" :icon="ShoppingCart"
main-label="Total Stok" :main-value="productStock.total_stock.toLocaleString('id-ID') main-label="Total Stok"
" :sub-label="'Rp' + :main-value="(
formatRupiah(productStock.total_value) + productStock.total_stock +
(productStock.total_reject > 0 productStock.total_reject +
? ' (' + productStock.total_reject + ' reject)' productStock.total_retail
: '') ).toLocaleString('id-ID')"
" :items="[ :sub-label="'Rp' + formatRupiah(productStock.total_value)"
:items="[
{ {
label: 'Produk', label: 'Stok Bagus',
value: productStock.total_products.toLocaleString( value: productStock.total_stock.toLocaleString(
'id-ID', 'id-ID',
), ),
}, },
{ {
label: 'Varian', label: 'Stok Reject',
value: productStock.total_variants.toLocaleString( value: productStock.total_reject.toLocaleString(
'id-ID', 'id-ID',
), ),
}, },
{ {
label: 'Kategori', label: 'Stok Ecer',
value: productStock.total_categories.toLocaleString( value: productStock.total_retail.toLocaleString(
'id-ID', 'id-ID',
), ),
}, },

View File

@ -17,6 +17,7 @@ const initialData = computed(() => ({
description: props.product.description ?? '', description: props.product.description ?? '',
category_ids: category_ids:
props.product.categories?.map((category) => category.id) ?? [], props.product.categories?.map((category) => category.id) ?? [],
status: props.product.status ?? 'active',
variants: (props.product.variants ?? []).map((variant) => ({ variants: (props.product.variants ?? []).map((variant) => ({
id: variant.id, id: variant.id,
name: variant.name, name: variant.name,

View File

@ -8,7 +8,7 @@ import {
useDataTableQuery, useDataTableQuery,
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import { ActiveStatus } from '@/constants/active-status'; import { ProductStatus } from '@/constants/active-status';
import { StockStatus } from '@/constants/stock-status'; import { StockStatus } from '@/constants/stock-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/master/products'; import { index, create } from '@/routes/admin/master/products';
@ -23,7 +23,7 @@ const props = defineProps<{
search: string; search: string;
sort?: string; sort?: string;
direction?: 'asc' | 'desc'; direction?: 'asc' | 'desc';
is_active?: string; status?: string;
category_id?: string; category_id?: string;
stock_status?: string; stock_status?: string;
}; };
@ -36,7 +36,7 @@ const { query, setSearch, setFilter, resetFilters, syncFromServer } =
useDataTableQuery({ useDataTableQuery({
url: index.url(), url: index.url(),
initial: { ...props.filters }, initial: { ...props.filters },
filterKeys: ['is_active', 'category_id', 'stock_status'], filterKeys: ['status', 'category_id', 'stock_status'],
}); });
useDataTableQuerySync(() => props.filters, syncFromServer); useDataTableQuerySync(() => props.filters, syncFromServer);
@ -52,12 +52,13 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
})), })),
}, },
{ {
key: 'is_active', key: 'status',
label: 'Status', label: 'Status',
type: 'select', type: 'select',
options: [ options: [
{ value: ActiveStatus.ACTIVE, label: 'Aktif' }, { value: ProductStatus.DRAFT, label: 'Draf' },
{ value: ActiveStatus.INACTIVE, label: 'Nonaktif' }, { value: ProductStatus.ACTIVE, label: 'Aktif' },
{ value: ProductStatus.INACTIVE, label: 'Nonaktif' },
], ],
}, },
{ {
@ -73,7 +74,7 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
const filterValues = computed(() => ({ const filterValues = computed(() => ({
category_id: query.value.category_id ?? '', category_id: query.value.category_id ?? '',
is_active: query.value.is_active ?? '', status: query.value.status ?? '',
stock_status: query.value.stock_status ?? '', stock_status: query.value.stock_status ?? '',
})); }));

View File

@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Plus, Save, Search, X } from '@lucide/vue'; import { FileText, Globe, Plus, Save, Search, X } from '@lucide/vue';
import { computed, 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';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { useVariantList } from '@/composables/useVariantList'; import { useVariantList } from '@/composables/useVariantList';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media'; import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
@ -185,8 +187,16 @@ const form = useForm({
name: props.initialData?.name ?? '', name: props.initialData?.name ?? '',
description: props.initialData?.description ?? '', description: props.initialData?.description ?? '',
category_ids: props.initialData?.category_ids ?? [], category_ids: props.initialData?.category_ids ?? [],
status: props.initialData?.status ?? 'active',
}); });
const isDraft = ref(props.initialData?.status === 'draft');
function toggleDraft(checked: boolean) {
isDraft.value = checked;
form.status = checked ? 'draft' : 'active';
}
const categoryError = computed(() => form.errors.category_ids); const categoryError = computed(() => form.errors.category_ids);
function toggleCategory(categoryId: number, checked: boolean) { function toggleCategory(categoryId: number, checked: boolean) {
@ -206,6 +216,7 @@ function buildFormData(): FormData {
formData.append('name', form.name.trim()); formData.append('name', form.name.trim());
formData.append('description', form.description.trim()); formData.append('description', form.description.trim());
formData.append('status', form.status);
form.category_ids.forEach((categoryId) => { form.category_ids.forEach((categoryId) => {
formData.append('category_ids[]', String(categoryId)); formData.append('category_ids[]', String(categoryId));
@ -299,16 +310,29 @@ function submit() {
</Button> </Button>
</div> </div>
<div class="flex items-center justify-between gap-2"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" @click="handleAddVariant"> <Button type="button" variant="outline" @click="handleAddVariant">
<Plus class="size-4" /> <Plus class="size-4" />
Tambah Varian Tambah Varian
</Button> </Button>
<Button type="submit" :disabled="form.processing || isUploading"> <div class="flex flex-col items-end gap-3 sm:flex-row sm:items-center sm:gap-4">
<Save class="size-4" /> <div class="flex items-center gap-2">
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }} <Switch :id="'draft-toggle'" :model-value="isDraft" @update:model-value="toggleDraft" />
</Button> <Label :for="'draft-toggle'" class="cursor-pointer">
<span class="flex items-center gap-1.5 text-sm whitespace-nowrap">
<FileText v-if="isDraft" class="size-4 text-muted-foreground" />
<Globe v-else class="size-4 text-muted-foreground" />
{{ isDraft ? 'Simpan sebagai Draft' : 'Publikasikan' }}
</span>
</Label>
</div>
<Button type="submit" :disabled="form.processing || isUploading" class="w-full sm:w-auto">
<Save class="size-4" />
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</div>
</div> </div>
</div> </div>
</form> </form>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { ref, watch } from 'vue'; import { computed, ref, watch } 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';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { ProductStatus } from '@/constants/active-status';
import { toggle_status } from '@/routes/admin/master/products'; import { toggle_status } from '@/routes/admin/master/products';
import type { ProductListItem } from '@/types/product'; import type { ProductListItem } from '@/types/product';
@ -14,38 +15,43 @@ const props = defineProps<{
const { can } = useCan(); const { can } = useCan();
function resolveIsActive(product: ProductListItem): boolean { function resolveStatus(product: ProductListItem): string {
return product.display_is_active ?? product.is_active; return product.display_status ?? product.status;
} }
const isActive = ref(resolveIsActive(props.product)); const currentStatus = ref(resolveStatus(props.product));
const processing = ref(false); const processing = ref(false);
const isActive = computed(() => currentStatus.value === ProductStatus.ACTIVE);
const isDraft = computed(() => currentStatus.value === ProductStatus.DRAFT);
watch( watch(
() => resolveIsActive(props.product), () => resolveStatus(props.product),
(value) => { (value) => {
isActive.value = value; currentStatus.value = value;
}, },
); );
function toggleStatus(checked: boolean) { function toggleStatus(checked: boolean) {
if (!can('products.toggle_status') || props.product.has_pending_request) { if (!can('products.toggle_status') || props.product.has_pending_request || isDraft.value) {
return; return;
} }
if (checked === isActive.value) { const newStatus = checked ? ProductStatus.ACTIVE : ProductStatus.INACTIVE;
if (newStatus === currentStatus.value) {
return; return;
} }
processing.value = true; processing.value = true;
isActive.value = checked; currentStatus.value = newStatus;
router.patch(toggle_status.url(props.product.id), { router.patch(toggle_status.url(props.product.id), {
is_active: checked, status: newStatus,
}, { }, {
preserveScroll: true, preserveScroll: true,
onError: (errors: any) => { onError: (errors: any) => {
isActive.value = resolveIsActive(props.product); currentStatus.value = resolveStatus(props.product);
if (errors.system) { if (errors.system) {
toast.error(errors.system); toast.error(errors.system);
@ -57,7 +63,7 @@ function toggleStatus(checked: boolean) {
}, },
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;
isActive.value = resolveIsActive(props.product); currentStatus.value = resolveStatus(props.product);
}, },
}); });
} }
@ -65,13 +71,18 @@ function toggleStatus(checked: boolean) {
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Switch <template v-if="isDraft">
:model-value="isActive" <Badge variant="secondary">Draf</Badge>
:disabled="processing || !can('products.toggle_status') || product.has_pending_request" </template>
@update:model-value="toggleStatus" <template v-else>
/> <Switch
<Badge :variant="isActive ? 'default' : 'secondary'"> :model-value="isActive"
{{ isActive ? 'Aktif' : 'Nonaktif' }} :disabled="processing || !can('products.toggle_status') || product.has_pending_request"
</Badge> @update:model-value="toggleStatus"
/>
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>
</template>
</div> </div>
</template> </template>

View File

@ -63,7 +63,7 @@ export interface Product {
name: string; name: string;
slug: string; slug: string;
description: string | null; description: string | null;
is_active: boolean; status: string;
categories: Category[]; categories: Category[];
variants: Variant[]; variants: Variant[];
} }
@ -77,7 +77,7 @@ export interface ProductListItem extends Product {
pending_request_action?: string; pending_request_action?: string;
pending_request_action_label?: string; pending_request_action_label?: string;
pending_request_submitted_by_name?: string; pending_request_submitted_by_name?: string;
display_is_active?: boolean; display_status?: string;
} }
export interface CategoryOption { export interface CategoryOption {
@ -102,6 +102,7 @@ export type ProductFormInitialData = {
name?: string; name?: string;
description?: string; description?: string;
category_ids?: number[]; category_ids?: number[];
status?: string;
variants?: Array<{ variants?: Array<{
id?: number; id?: number;
name?: string; name?: string;

View File

@ -5,6 +5,7 @@
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\Permission as PermissionEnum; use App\Enums\Permission as PermissionEnum;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\OrderItem; use App\Models\OrderItem;
@ -621,7 +622,7 @@ function setupOrderDraftItems(User $user): ProductVariant
test('catalogItems excludes variants where all stock types are zero', function () { test('catalogItems excludes variants where all stock types are zero', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE); $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$product = Product::factory()->create(['is_active' => true]); $product = Product::factory()->create(['status' => ProductStatus::ACTIVE]);
$variantWithStock = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0]); $variantWithStock = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantAllZero = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0]); $variantAllZero = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0]);
@ -643,7 +644,7 @@ function setupOrderDraftItems(User $user): ProductVariant
test('catalogItems includes all-zero-stock variants already in order', function () { test('catalogItems includes all-zero-stock variants already in order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE); $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE);
$product = Product::factory()->create(['is_active' => true]); $product = Product::factory()->create(['status' => ProductStatus::ACTIVE]);
$variantAllZero = ProductVariant::factory()->create([ $variantAllZero = ProductVariant::factory()->create([
'product_id' => $product->id, 'product_id' => $product->id,
'stock' => 0, 'stock' => 0,

View File

@ -3,6 +3,7 @@
use App\Enums\OwnerVerificationAction; use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus; use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum; use App\Enums\Permission as PermissionEnum;
use App\Enums\ProductStatus;
use App\Models\Category; use App\Models\Category;
use App\Models\OwnerVerificationRequest; use App\Models\OwnerVerificationRequest;
use App\Models\Product; use App\Models\Product;
@ -191,7 +192,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
createProductWithVariants(); createProductWithVariants();
$this->actingAs($user) $this->actingAs($user)
->get(route('admin.master.products.index', ['is_active' => '1'])) ->get(route('admin.master.products.index', ['status' => 'active']))
->assertOk(); ->assertOk();
}); });
@ -243,6 +244,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->actingAs($user) $this->actingAs($user)
->post(route('admin.master.products.store'), [ ->post(route('admin.master.products.store'), [
'name' => 'Produk Baru', 'name' => 'Produk Baru',
'status' => 'active',
'description' => 'Deskripsi produk', 'description' => 'Deskripsi produk',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'variants' => [ 'variants' => [
@ -259,7 +261,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->assertDatabaseHas('products', [ $this->assertDatabaseHas('products', [
'name' => 'Produk Baru', 'name' => 'Produk Baru',
'is_active' => false, 'status' => ProductStatus::INACTIVE->value,
]); ]);
}); });
@ -421,6 +423,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'name' => 'Produk Lengkap', 'name' => 'Produk Lengkap',
'description' => 'Deskripsi lengkap', 'description' => 'Deskripsi lengkap',
'category_ids' => [$cat1->id, $cat2->id], 'category_ids' => [$cat1->id, $cat2->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantWithImage('S', 5), variantWithImage('S', 5),
variantWithImage('M', 10), variantWithImage('M', 10),
@ -430,14 +433,14 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$product = Product::where('name', 'Produk Lengkap')->first(); $product = Product::where('name', 'Produk Lengkap')->first();
expect($product)->not->toBeNull(); expect($product)->not->toBeNull();
expect($product->is_active)->toBeFalse(); expect($product->status)->toBe(ProductStatus::INACTIVE);
expect($product->categories)->toHaveCount(2); expect($product->categories)->toHaveCount(2);
expect($product->variants)->toHaveCount(3); expect($product->variants)->toHaveCount(3);
approveLatestOwnerVerificationRequest($verifier); approveLatestOwnerVerificationRequest($verifier);
$product->refresh(); $product->refresh();
expect($product->is_active)->toBeTrue(); expect($product->status)->toBe(ProductStatus::ACTIVE);
}); });
test('approving create request auto-generates product slug', function () { test('approving create request auto-generates product slug', function () {
@ -450,13 +453,14 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
->post(route('admin.master.products.store'), [ ->post(route('admin.master.products.store'), [
'name' => 'Batik Modern Elegan', 'name' => 'Batik Modern Elegan',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [variantWithImage()], 'variants' => [variantWithImage()],
]); ]);
$this->assertDatabaseHas('products', [ $this->assertDatabaseHas('products', [
'name' => 'Batik Modern Elegan', 'name' => 'Batik Modern Elegan',
'slug' => 'batik-modern-elegan', 'slug' => 'batik-modern-elegan',
'is_active' => false, 'status' => ProductStatus::INACTIVE->value,
]); ]);
approveLatestOwnerVerificationRequest($verifier); approveLatestOwnerVerificationRequest($verifier);
@ -464,7 +468,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->assertDatabaseHas('products', [ $this->assertDatabaseHas('products', [
'name' => 'Batik Modern Elegan', 'name' => 'Batik Modern Elegan',
'slug' => 'batik-modern-elegan', 'slug' => 'batik-modern-elegan',
'is_active' => true, 'status' => ProductStatus::ACTIVE->value,
]); ]);
}); });
@ -478,6 +482,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
->post(route('admin.master.products.store'), [ ->post(route('admin.master.products.store'), [
'name' => 'Produk Ditolak', 'name' => 'Produk Ditolak',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [variantWithImage()], 'variants' => [variantWithImage()],
]); ]);
@ -537,6 +542,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
->put(route('admin.master.products.update', $product), [ ->put(route('admin.master.products.update', $product), [
'name' => 'Nama Diubah', 'name' => 'Nama Diubah',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantUpdateData($variant->id, $variant->name, $variant->stock), variantUpdateData($variant->id, $variant->name, $variant->stock),
], ],
@ -563,6 +569,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'name' => 'Nama Diubah', 'name' => 'Nama Diubah',
'description' => 'Deskripsi baru', 'description' => 'Deskripsi baru',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantUpdateData($variant->id, 'New Variant', 20), variantUpdateData($variant->id, 'New Variant', 20),
], ],
@ -592,6 +599,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'name' => 'Nama Diubah', 'name' => 'Nama Diubah',
'description' => 'Deskripsi baru', 'description' => 'Deskripsi baru',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantUpdateData($variant->id, 'New Variant', 20), variantUpdateData($variant->id, 'New Variant', 20),
], ],
@ -676,6 +684,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
->put(route('admin.master.products.update', $product), [ ->put(route('admin.master.products.update', $product), [
'name' => 'Updated Product', 'name' => 'Updated Product',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantUpdateData($variantToKeep->id, 'Kept Variant', 5), variantUpdateData($variantToKeep->id, 'Kept Variant', 5),
], ],
@ -699,7 +708,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
]) ])
->assertRedirect(); ->assertRedirect();
@ -710,7 +719,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'status' => OwnerVerificationStatus::PENDING->value, 'status' => OwnerVerificationStatus::PENDING->value,
]); ]);
expect($product->fresh()->is_active)->toBeTrue(); expect($product->fresh()->status)->toBe(ProductStatus::ACTIVE);
}); });
test('product index exposes display is active for pending toggle status request', function () { test('product index exposes display is active for pending toggle status request', function () {
@ -720,11 +729,11 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
); );
$product = createProductWithVariants(); $product = createProductWithVariants();
$product->update(['is_active' => true]); $product->update(['status' => ProductStatus::ACTIVE]);
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
]) ])
->assertRedirect(); ->assertRedirect();
@ -734,8 +743,8 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page $response->assertInertia(fn ($page) => $page
->where('products.data.0.id', $product->id) ->where('products.data.0.id', $product->id)
->where('products.data.0.is_active', true) ->where('products.data.0.status', 'active')
->where('products.data.0.display_is_active', false) ->where('products.data.0.display_status', 'inactive')
->where('products.data.0.has_pending_request', true) ->where('products.data.0.has_pending_request', true)
->where('products.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value) ->where('products.data.0.pending_request_action', OwnerVerificationAction::TOGGLE_STATUS->value)
); );
@ -749,19 +758,19 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
]); ]);
approveLatestOwnerVerificationRequest($verifier); approveLatestOwnerVerificationRequest($verifier);
expect($product->fresh()->is_active)->toBeFalse(); expect($product->fresh()->status)->toBe(ProductStatus::INACTIVE);
}); });
test('guest cannot toggle product status', function () { test('guest cannot toggle product status', function () {
$product = createProductWithVariants(); $product = createProductWithVariants();
$this->patch(route('admin.master.products.toggle_status', $product), [ $this->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
])->assertRedirect(route('login')); ])->assertRedirect(route('login'));
}); });
@ -772,21 +781,21 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
]) ])
->assertForbidden(); ->assertForbidden();
}); });
test('is_active field is required', function () { test('status field is required', function () {
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS); $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS);
$product = createProductWithVariants(); $product = createProductWithVariants();
$this->actingAs($user) $this->actingAs($user)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => null, 'status' => null,
]) ])
->assertSessionHasErrors('is_active'); ->assertSessionHasErrors('status');
}); });
}); });
@ -893,14 +902,15 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
expect($product->fresh()->variants)->toHaveCount(3); expect($product->fresh()->variants)->toHaveCount(3);
}); });
test('product has is_active cast to boolean', function () { test('product has status cast to enum', function () {
$product = Product::factory()->create(['is_active' => true]); $product = Product::factory()->create();
expect($product->is_active)->toBeTrue(); expect($product->status)->toBeInstanceOf(ProductStatus::class);
expect($product->status)->toBe(ProductStatus::ACTIVE);
$product->update(['is_active' => false]); $product->update(['status' => ProductStatus::INACTIVE]);
expect($product->fresh()->is_active)->toBeFalse(); expect($product->fresh()->status)->toBe(ProductStatus::INACTIVE);
}); });
test('product has total_stock_formatted accessor', function () { test('product has total_stock_formatted accessor', function () {
@ -1022,6 +1032,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'name' => 'Produk Owner', 'name' => 'Produk Owner',
'description' => 'Deskripsi', 'description' => 'Deskripsi',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantWithImage('All Size', 10), variantWithImage('All Size', 10),
], ],
@ -1030,7 +1041,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$product = Product::where('name', 'Produk Owner')->first(); $product = Product::where('name', 'Produk Owner')->first();
expect($product)->not->toBeNull(); expect($product)->not->toBeNull();
expect($product->is_active)->toBeTrue(); expect($product->status)->toBe(ProductStatus::ACTIVE);
$this->assertDatabaseMissing('owner_verification_requests', [ $this->assertDatabaseMissing('owner_verification_requests', [
'subject_id' => $product->id, 'subject_id' => $product->id,
@ -1054,6 +1065,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
'name' => 'Produk Updated', 'name' => 'Produk Updated',
'description' => 'Deskripsi baru', 'description' => 'Deskripsi baru',
'category_ids' => [$category->id], 'category_ids' => [$category->id],
'status' => 'active',
'variants' => [ 'variants' => [
variantUpdateData($variant->id, 'Updated Variant', 25), variantUpdateData($variant->id, 'Updated Variant', 25),
], ],
@ -1091,10 +1103,10 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
$this->actingAs($owner) $this->actingAs($owner)
->patch(route('admin.master.products.toggle_status', $product), [ ->patch(route('admin.master.products.toggle_status', $product), [
'is_active' => false, 'status' => 'inactive',
]); ]);
expect($product->fresh()->is_active)->toBeFalse(); expect($product->fresh()->status)->toBe(ProductStatus::INACTIVE);
}); });
}); });