diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php new file mode 100644 index 0000000..43ced45 --- /dev/null +++ b/app/Enums/ProductStatus.php @@ -0,0 +1,23 @@ + 'Draf', + self::ACTIVE => 'Aktif', + self::INACTIVE => 'Nonaktif', + }; + } +} diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 29e3c71..4749457 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -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'], diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index 2b7d507..3efb4fa 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -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'), diff --git a/app/Http/Requests/Admin/ToggleStatusRequest.php b/app/Http/Requests/Admin/ToggleStatusRequest.php index 4387a34..64e2b98 100644 --- a/app/Http/Requests/Admin/ToggleStatusRequest.php +++ b/app/Http/Requests/Admin/ToggleStatusRequest.php @@ -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', ]; } } diff --git a/app/Models/OwnerVerificationRequest.php b/app/Models/OwnerVerificationRequest.php index 2adf03a..06bf7b8 100644 --- a/app/Models/OwnerVerificationRequest.php +++ b/app/Models/OwnerVerificationRequest.php @@ -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 diff --git a/app/Models/Product.php b/app/Models/Product.php index e39f671..2f4936b 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -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 diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index ddc8451..5d0e759 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -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; diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index b46f66e..361e093 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -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), diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php index 4546a50..e7b4ee5 100644 --- a/app/Support/OwnerVerification/VerificationChangeFormatter.php +++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php @@ -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 = [ diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 9bf337e..3bfb192 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -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, ]); } } diff --git a/database/migrations/2026_07_18_100100_add_status_to_products_table.php b/database/migrations/2026_07_18_100100_add_status_to_products_table.php new file mode 100644 index 0000000..961ce7c --- /dev/null +++ b/database/migrations/2026_07_18_100100_add_status_to_products_table.php @@ -0,0 +1,38 @@ +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'); + }); + } +}; diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 5a7bcb4..90371cd 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -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( diff --git a/resources/js/constants/active-status.ts b/resources/js/constants/active-status.ts index 2a35d4e..2a6aac1 100644 --- a/resources/js/constants/active-status.ts +++ b/resources/js/constants/active-status.ts @@ -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', diff --git a/resources/js/pages/admin/Analysis.vue b/resources/js/pages/admin/Analysis.vue index 5f4241c..281d76a 100644 --- a/resources/js/pages/admin/Analysis.vue +++ b/resources/js/pages/admin/Analysis.vue @@ -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], () => { ]" /> 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() { -
+
- +
+
+ + +
+ + +
diff --git a/resources/js/pages/admin/master/products/table/product-status-toggle.vue b/resources/js/pages/admin/master/products/table/product-status-toggle.vue index 8e6a5a6..8e1c6c1 100644 --- a/resources/js/pages/admin/master/products/table/product-status-toggle.vue +++ b/resources/js/pages/admin/master/products/table/product-status-toggle.vue @@ -1,10 +1,11 @@