feat: add is_featured attribute and toggle functionality for products

This commit is contained in:
Yoga Pangestu 2026-08-13 12:02:49 +07:00
parent d5f2a66a14
commit 1c9532199c
14 changed files with 84 additions and 12 deletions

View File

@ -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

View File

@ -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';

View File

@ -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);

View File

@ -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',

View File

@ -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',

View File

@ -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
{

View File

@ -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([

View File

@ -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();

View File

@ -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',

View File

@ -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/'):

View File

@ -32,6 +32,7 @@ export type Product = {
slug: string;
description: string | null;
status: string;
is_featured: boolean;
rejection_reason: string | null;
categories: {
id: number;

View File

@ -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
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Halaman Depan
</label>
<Select
value={filters.featured ?? 'all'}
onValueChange={(value) => applyFilter('featured', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="true">Ditampilkan</SelectItem>
<SelectItem value="false">Tidak Ditampilkan</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
@ -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)}
/>

View File

@ -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({
</span>
)}
</div>
{(product.status === 'active' || product.status === 'inactive') && (
<div className="mt-2">
<ToggleStatus
url={toggleFeaturedUrl(product.id)}
checked={product.is_featured}
label={product.is_featured ? 'Ditampilkan di Halaman Depan' : 'Tidak Ditampilkan'}
/>
</div>
)}
</div>
{/* Actions */}

View File

@ -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');