From 1c9532199c7286dbf759b864ad3204da02ab0ed1 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 13 Aug 2026 12:02:49 +0700 Subject: [PATCH] feat: add is_featured attribute and toggle functionality for products --- .ai/REFERENCE.md | 7 +++--- app/Enums/Permission.php | 1 + .../Master/Product/ProductController.php | 14 +++++++++-- app/Http/Controllers/HomepageController.php | 1 + .../Admin/Master/Product/ProductRequest.php | 2 ++ app/Models/Product.php | 7 ++++++ .../Admin/Master/Product/ProductService.php | 12 ++++++++- ...026_06_10_100001_create_products_table.php | 1 + database/seeders/RolePermissionSeeder.php | 3 ++- resources/js/app.tsx | 9 ++++--- .../js/pages/admin/master/product/columns.tsx | 1 + .../js/pages/admin/master/product/index.tsx | 25 ++++++++++++++++++- .../admin/master/product/product-card.tsx | 12 +++++++++ routes/web.php | 1 + 14 files changed, 84 insertions(+), 12 deletions(-) diff --git a/.ai/REFERENCE.md b/.ai/REFERENCE.md index c81b583..5b3bcd6 100644 --- a/.ai/REFERENCE.md +++ b/.ai/REFERENCE.md @@ -46,9 +46,9 @@ ### `product_categories` → Category (Pivot) - Relations: category(BelongsTo→Category), product(BelongsTo→Product) ### `products` → Product -`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at` -- Casts: status(ProductStatus) -- Scopes: active(), draft(), inactive(), pending(), rejected() +`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `is_featured`(bool,default:false) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at` +- Casts: status(ProductStatus), is_featured(bool) +- Scopes: active(), draft(), featured(), inactive(), pending(), rejected() - Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant) ### `product_variants` → ProductVariant @@ -336,6 +336,7 @@ ### Master | `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) | | `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint | | `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | +| `ProductRequest` | `is_featured` | `nullable, boolean` | ✅ bool | | `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | ### Finance diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 2def17c..f1af26c 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -71,6 +71,7 @@ enum Permission: string case PRODUCTS_UPDATE = 'products.update'; case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status'; + case PRODUCTS_TOGGLE_FEATURED = 'products.toggle_featured'; case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock'; case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations'; diff --git a/app/Http/Controllers/Admin/Master/Product/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php index c45e26f..3ffbaa8 100644 --- a/app/Http/Controllers/Admin/Master/Product/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -24,11 +24,11 @@ public function index(PaginatedRequest $request): Response return Inertia::render('admin/master/product/index', [ 'products' => $this->service->paginated( ...$request->validatedWithDefaults(), - filters: $request->only(['status', 'stock', 'category', 'name']), + filters: $request->only(['status', 'stock', 'category', 'name', 'featured']), ), 'categories' => $this->categoryService->getAll(), 'productNames' => $this->service->getNames(), - 'filters' => $request->only(['status', 'stock', 'category', 'name']), + 'filters' => $request->only(['status', 'stock', 'category', 'name', 'featured']), ]); } @@ -87,6 +87,16 @@ public function toggleStatus(Product $product): RedirectResponse return back(); } + public function toggleFeatured(Product $product): RedirectResponse + { + $this->service->toggleFeatured($product); + $featured = $product->fresh()->is_featured; + + Inertia::flash('toast', ['type' => 'success', 'message' => $featured ? 'Produk berhasil ditampilkan di halaman depan.' : 'Produk berhasil disembunyikan dari halaman depan.']); + + return back(); + } + public function approve(Product $product): RedirectResponse { $this->service->approve($product); diff --git a/app/Http/Controllers/HomepageController.php b/app/Http/Controllers/HomepageController.php index 62ef987..71933c8 100644 --- a/app/Http/Controllers/HomepageController.php +++ b/app/Http/Controllers/HomepageController.php @@ -33,6 +33,7 @@ public function __invoke(Request $request): Response $productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status']) ->active() + ->featured() ->with([ 'categories:id,name,slug', 'productVariants:id,product_id,name,stock', diff --git a/app/Http/Requests/Admin/Master/Product/ProductRequest.php b/app/Http/Requests/Admin/Master/Product/ProductRequest.php index a113bd0..7c4b065 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductRequest.php @@ -37,6 +37,7 @@ public function rules(): array ], 'description' => ['nullable', 'string'], 'status' => ['nullable', Rule::in(ProductStatus::values())], + 'is_featured' => ['nullable', 'boolean'], 'category_ids' => ['required', 'array', 'min:1'], 'category_ids.*' => [Rule::exists('categories', 'id')], 'use_same_price' => ['nullable', 'boolean'], @@ -64,6 +65,7 @@ public function attributes(): array 'name' => 'nama produk', 'description' => 'deskripsi', 'status' => 'status', + 'is_featured' => 'ditampilkan di halaman depan', 'category_ids' => 'kategori', 'variants' => 'varian', 'variants.*.name' => 'nama varian', diff --git a/app/Models/Product.php b/app/Models/Product.php index 7d12c09..bd1be55 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -26,6 +26,7 @@ protected function casts(): array { return [ 'status' => ProductStatus::class, + 'is_featured' => 'boolean', ]; } @@ -55,6 +56,12 @@ protected function draft(Builder $query): void $query->where('status', ProductStatus::DRAFT); } + #[Scope] + protected function featured(Builder $query): void + { + $query->where('is_featured', true); + } + #[Scope] protected function inactive(Builder $query): void { diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index 61e78b4..b2239cc 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -35,7 +35,7 @@ public function getNames(): Collection public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { $paginator = Product::query() - ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason']) + ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured']) ->with([ 'categories:id,name', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', @@ -49,6 +49,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { $cq->where('categories.id', $categoryId); })) + ->when($filters['featured'] ?? null, fn ($q, $featured) => $q->where('is_featured', $featured === 'true')) ->when(($filters['stock'] ?? null) === 'empty', function ($q) { $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) = 0'); }) @@ -433,6 +434,15 @@ public function toggleStatus(Product $product): void ]); } + public function toggleFeatured(Product $product): void + { + $this->assertNotPending($product); + + $product->update([ + 'is_featured' => ! $product->is_featured, + ]); + } + public function approve(Product $product): void { $product->update([ diff --git a/database/migrations/2026_06_10_100001_create_products_table.php b/database/migrations/2026_06_10_100001_create_products_table.php index ad62f65..100c4dd 100644 --- a/database/migrations/2026_06_10_100001_create_products_table.php +++ b/database/migrations/2026_06_10_100001_create_products_table.php @@ -16,6 +16,7 @@ public function up(): void $table->string('slug', 200)->unique(); $table->text('description')->nullable(); $table->string('status', 20)->default(ProductStatus::ACTIVE->value); + $table->boolean('is_featured')->default(false); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index dbdd8a4..5017c0f 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -21,7 +21,7 @@ public function run(): void 'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'], 'categories' => ['view', 'create', 'update', 'delete'], 'customers' => ['view', 'create', 'update', 'delete'], - 'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'transfer_stock', 'view_stock_mutations'], + 'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'], 'stocks' => ['view'], 'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'], 'cuttings' => ['view', 'create', 'update', 'delete', 'complete'], @@ -124,6 +124,7 @@ public function run(): void 'products.update', 'products.delete', 'products.toggle_status', + 'products.toggle_featured', 'products.transfer_stock', 'products.view_stock_mutations', diff --git a/resources/js/app.tsx b/resources/js/app.tsx index fafdf8e..03dc7d8 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -1,13 +1,12 @@ -import { createInertiaApp } from '@inertiajs/react'; -import { registerSW } from 'virtual:pwa-register'; -import { FlashToast } from '@/components/notifications'; -import { PWAUpdateToast } from '@/components/notifications'; +import { FlashToast, PWAUpdateToast } from '@/components/notifications'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { initializeTheme } from '@/hooks/use-appearance'; import AppLayout from '@/layouts/app-layout'; import AuthLayout from '@/layouts/auth-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { createInertiaApp } from '@inertiajs/react'; +import { registerSW } from 'virtual:pwa-register'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -26,6 +25,8 @@ createInertiaApp({ switch (true) { case name === 'admin/manage/cutting/show': return null; + case name === 'welcome': + return null; case name.startsWith('auth/'): return AuthLayout; case name.startsWith('settings/'): diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx index 7392e72..509035d 100644 --- a/resources/js/pages/admin/master/product/columns.tsx +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -32,6 +32,7 @@ export type Product = { slug: string; description: string | null; status: string; + is_featured: boolean; rejection_reason: string | null; categories: { id: number; diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index 2190835..90aab3b 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -32,6 +32,7 @@ import { edit as productEdit, index as productIndex, toggleStatus, + toggleFeatured as productToggleFeatured, approve as productApprove, reject as productReject, resubmit as productResubmit, @@ -63,6 +64,7 @@ type Props = { name?: string; stock?: string; category?: string; + featured?: string; }; }; @@ -160,7 +162,8 @@ export default function ProductIndex({ products, categories, productNames, filte filters.status || filters.name || filters.stock || - filters.category, + filters.category || + filters.featured, )} onClear={clearFilters} > @@ -265,6 +268,25 @@ export default function ProductIndex({ products, categories, productNames, filte + +
+ + +
); @@ -321,6 +343,7 @@ export default function ProductIndex({ products, categories, productNames, filte onDelete={(p) => setDeleting(p)} onReject={(p) => setRejecting(p)} toggleStatusUrl={(id) => toggleStatus.url(id)} + toggleFeaturedUrl={(id) => productToggleFeatured.url(id)} approveUrl={(id) => productApprove.url(id)} resubmitUrl={(id) => productResubmit.url(id)} /> diff --git a/resources/js/pages/admin/master/product/product-card.tsx b/resources/js/pages/admin/master/product/product-card.tsx index 5e73a74..f99c9fa 100644 --- a/resources/js/pages/admin/master/product/product-card.tsx +++ b/resources/js/pages/admin/master/product/product-card.tsx @@ -42,6 +42,7 @@ export type ProductCardRowParams = { onDelete: (product: Product) => void; onReject: (product: Product) => void; toggleStatusUrl: (id: number) => string; + toggleFeaturedUrl: (id: number) => string; approveUrl: (id: number) => string; resubmitUrl: (id: number) => string; }; @@ -55,6 +56,7 @@ export function ProductCardRow({ onDelete, onReject, toggleStatusUrl, + toggleFeaturedUrl, approveUrl, resubmitUrl, }: ProductCardRowParams) { @@ -173,6 +175,16 @@ export function ProductCardRow({ )} + + {(product.status === 'active' || product.status === 'inactive') && ( +
+ +
+ )} {/* Actions */} diff --git a/routes/web.php b/routes/web.php index b7eab06..98c45ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,6 +41,7 @@ Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete'); Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status')->middleware('permission:products.toggle_status'); + Route::post('products/{product}/toggle-featured', [ProductController::class, 'toggleFeatured'])->name('products.toggle-featured')->middleware('permission:products.toggle_featured'); Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update'); Route::post('products/{product}/reject', [ProductController::class, 'reject'])->name('products.reject')->middleware('permission:products.update'); Route::post('products/{product}/resubmit', [ProductController::class, 'resubmit'])->name('products.resubmit')->middleware('permission:products.update');