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['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();
$stockStatus = $request->string('stock_status')->toString();
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(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
'status' => $status,
'category_id' => $categoryId,
'stock_status' => $stockStatus,
'search_id' => $tableQuery['search_id'],

View File

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

View File

@ -2,7 +2,9 @@
namespace App\Http\Requests\Admin;
use App\Enums\ProductStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ToggleStatusRequest extends FormRequest
{
@ -17,7 +19,7 @@ public function authorize(): bool
public function rules(): array
{
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
{
return [
'is_active' => 'aktif',
'status' => 'status',
];
}
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission;
use App\Enums\ProductStatus;
use App\Models\Category;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
@ -30,7 +31,7 @@ public function __construct(
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()
->with([
@ -50,7 +51,7 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$search = $tableQuery['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 {
$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_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username);
$product->setAttribute(
'display_is_active',
$pendingRequest?->pendingToggleIsActive() ?? $product->is_active,
'display_status',
$pendingRequest?->pendingToggleStatus() ?? $product->status->value,
);
return $product;
@ -120,13 +121,14 @@ public function findForEdit(Product $product): Product
public function create(array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$isDraft = ($validated['status'] ?? '') === ProductStatus::DRAFT->value;
$product = $this->runInTransaction(
function () use ($validated, $user, $isOwner): Product {
function () use ($validated, $user, $isOwner, $isDraft): Product {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_active' => $isOwner,
'status' => $isDraft ? ProductStatus::DRAFT : ($isOwner ? ProductStatus::ACTIVE : ProductStatus::INACTIVE),
]);
$product->categories()->sync($validated['category_ids'] ?? []);
@ -150,7 +152,7 @@ function () use ($validated, $user, $isOwner): Product {
}
}
if (! $isOwner) {
if (! $isOwner && ! $isDraft) {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::CREATE,
'status' => OwnerVerificationStatus::PENDING,
@ -169,6 +171,16 @@ function () use ($validated, $user, $isOwner): Product {
'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) {
$this->cacheForgetByPattern('master:products:*');
@ -193,10 +205,14 @@ function () use ($validated, $user, $isOwner): Product {
public function update(Product $product, array $validated, User $user): void
{
$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(
function () use ($validated, $product, $user, $isOwner): void {
if ($isOwner) {
function () use ($validated, $product, $user, $canEditDirectly): void {
if ($canEditDirectly) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload);
@ -253,6 +269,24 @@ function () use ($validated, $product, $user, $isOwner): void {
'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('prices:*');
$this->cacheForget('homepage:page_data');
@ -370,16 +404,17 @@ function () use ($product, $user): void {
public function toggleStatus(Product $product, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$newStatus = ProductStatus::tryFrom($validated['status'] ?? '');
if ($isOwner) {
$product->update([
'is_active' => (bool) $validated['is_active'],
'status' => $newStatus,
]);
$this->cacheForgetByPattern('master:products:*');
$this->cacheForget('homepage:page_data');
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
$statusLabel = $newStatus?->label() ?? 'unknown';
$this->notifyOwner(
'Ubah Status Produk',
@ -391,7 +426,7 @@ public function toggleStatus(Product $product, array $validated, User $user): vo
}
$this->runInTransaction(
function () use ($product, $validated, $user): void {
function () use ($product, $user, $newStatus): void {
OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS,
'status' => OwnerVerificationStatus::PENDING,
@ -401,11 +436,11 @@ function () use ($product, $validated, $user): void {
'payload' => [
'old' => [
'name' => $product->name,
'is_active' => $product->is_active,
'status' => $product->status->value,
],
'new' => [
'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',
);
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
$statusLabel = $newStatus?->label() ?? 'unknown';
$this->notifyForPendingRequest(
$user,
@ -478,7 +513,7 @@ public function applyCreate(OwnerVerificationRequest $verificationRequest): void
}
$product->update([
'is_active' => true,
'status' => ProductStatus::ACTIVE,
]);
}
@ -505,10 +540,13 @@ private function applyPayloadToProduct(
'description' => $payload['description'] ?? null,
]);
if (array_key_exists('is_active', $payload)) {
$product->update([
'is_active' => (bool) $payload['is_active'],
]);
if (array_key_exists('status', $payload)) {
$status = ProductStatus::tryFrom($payload['status']);
if ($status !== null) {
$product->update([
'status' => $status,
]);
}
}
$product->categories()->sync($payload['category_ids'] ?? []);
@ -609,8 +647,10 @@ public function applyToggleStatus(OwnerVerificationRequest $verificationRequest)
$newPayload = $this->payloadNew($verificationRequest);
$status = ProductStatus::tryFrom($newPayload['status'] ?? '');
$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,
'description' => $product->description,
'category_ids' => $product->categories->pluck('id')->all(),
'is_active' => $product->is_active,
'status' => $product->status->value,
'variants' => $product->variants
->map(fn (ProductVariant $variant) => [
'id' => $variant->id,
@ -744,6 +784,7 @@ private function buildPayloadFromValidated(array $validated): array
return [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'status' => $validated['status'] ?? ProductStatus::ACTIVE->value,
'category_ids' => $validated['category_ids'] ?? [],
'variants' => collect($validated['variants'] ?? [])
->map(fn (array $variantData) => [
@ -820,7 +861,7 @@ private function copyRequestVariantImages(OwnerVerificationRequest $verification
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);
return;

View File

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

View File

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

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\ProductStatus;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@ -19,14 +20,14 @@ public function definition(): array
'name' => Str::limit($name, 200, ''),
'slug' => Str::slug($name),
'description' => fake()->optional()->paragraph(),
'is_active' => true,
'status' => ProductStatus::ACTIVE,
];
}
public function inactive(): static
{
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;
use App\Enums\ProductStatus;
use App\Models\Category;
use App\Models\Product;
use App\Models\ProductVariant;
@ -77,7 +78,7 @@ public function run(): void
'name' => $productData['name'],
'slug' => str()->slug($productData['name']),
'description' => $productData['description'],
'is_active' => true,
'status' => ProductStatus::ACTIVE,
]);
$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 = {
ACTIVE: '1',
INACTIVE: '0',

View File

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

View File

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

View File

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

View File

@ -1,12 +1,14 @@
<script setup lang="ts">
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 { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useCan } from '@/composables/useCan';
import { useVariantList } from '@/composables/useVariantList';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
@ -185,8 +187,16 @@ const form = useForm({
name: props.initialData?.name ?? '',
description: props.initialData?.description ?? '',
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);
function toggleCategory(categoryId: number, checked: boolean) {
@ -206,6 +216,7 @@ function buildFormData(): FormData {
formData.append('name', form.name.trim());
formData.append('description', form.description.trim());
formData.append('status', form.status);
form.category_ids.forEach((categoryId) => {
formData.append('category_ids[]', String(categoryId));
@ -299,16 +310,29 @@ function submit() {
</Button>
</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">
<Plus class="size-4" />
Tambah Varian
</Button>
<Button type="submit" :disabled="form.processing || isUploading">
<Save class="size-4" />
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
<div class="flex flex-col items-end gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex items-center gap-2">
<Switch :id="'draft-toggle'" :model-value="isDraft" @update:model-value="toggleDraft" />
<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>
</form>

View File

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

View File

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

View File

@ -5,6 +5,7 @@
use App\Enums\PaymentType;
use App\Enums\Permission as PermissionEnum;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
@ -621,7 +622,7 @@ function setupOrderDraftItems(User $user): ProductVariant
test('catalogItems excludes variants where all stock types are zero', function () {
$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]);
$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 () {
$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([
'product_id' => $product->id,
'stock' => 0,

View File

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