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) - Relations: category(BelongsTo→Category), product(BelongsTo→Product)
### `products` → 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` `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) - Casts: status(ProductStatus), is_featured(bool)
- Scopes: active(), draft(), inactive(), pending(), rejected() - Scopes: active(), draft(), featured(), inactive(), pending(), rejected()
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant) - Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
### `product_variants` → ProductVariant ### `product_variants` → ProductVariant
@ -336,6 +336,7 @@ ### Master
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) | | `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint | | `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | | `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `ProductRequest` | `is_featured` | `nullable, boolean` | ✅ bool |
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | | `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
### Finance ### Finance

View File

@ -71,6 +71,7 @@ enum Permission: string
case PRODUCTS_UPDATE = 'products.update'; case PRODUCTS_UPDATE = 'products.update';
case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status';
case PRODUCTS_TOGGLE_FEATURED = 'products.toggle_featured';
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock'; case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations'; 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', [ return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated( 'products' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['status', 'stock', 'category', 'name']), filters: $request->only(['status', 'stock', 'category', 'name', 'featured']),
), ),
'categories' => $this->categoryService->getAll(), 'categories' => $this->categoryService->getAll(),
'productNames' => $this->service->getNames(), '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(); 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 public function approve(Product $product): RedirectResponse
{ {
$this->service->approve($product); $this->service->approve($product);

View File

@ -33,6 +33,7 @@ public function __invoke(Request $request): Response
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status']) $productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
->active() ->active()
->featured()
->with([ ->with([
'categories:id,name,slug', 'categories:id,name,slug',
'productVariants:id,product_id,name,stock', 'productVariants:id,product_id,name,stock',

View File

@ -37,6 +37,7 @@ public function rules(): array
], ],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'status' => ['nullable', Rule::in(ProductStatus::values())], 'status' => ['nullable', Rule::in(ProductStatus::values())],
'is_featured' => ['nullable', 'boolean'],
'category_ids' => ['required', 'array', 'min:1'], 'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => [Rule::exists('categories', 'id')], 'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'], 'use_same_price' => ['nullable', 'boolean'],
@ -64,6 +65,7 @@ public function attributes(): array
'name' => 'nama produk', 'name' => 'nama produk',
'description' => 'deskripsi', 'description' => 'deskripsi',
'status' => 'status', 'status' => 'status',
'is_featured' => 'ditampilkan di halaman depan',
'category_ids' => 'kategori', 'category_ids' => 'kategori',
'variants' => 'varian', 'variants' => 'varian',
'variants.*.name' => 'nama varian', 'variants.*.name' => 'nama varian',

View File

@ -26,6 +26,7 @@ protected function casts(): array
{ {
return [ return [
'status' => ProductStatus::class, 'status' => ProductStatus::class,
'is_featured' => 'boolean',
]; ];
} }
@ -55,6 +56,12 @@ protected function draft(Builder $query): void
$query->where('status', ProductStatus::DRAFT); $query->where('status', ProductStatus::DRAFT);
} }
#[Scope]
protected function featured(Builder $query): void
{
$query->where('is_featured', true);
}
#[Scope] #[Scope]
protected function inactive(Builder $query): void 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Product::query() $paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason']) ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured'])
->with([ ->with([
'categories:id,name', 'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', '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) { ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $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) { ->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'); $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 public function approve(Product $product): void
{ {
$product->update([ $product->update([

View File

@ -16,6 +16,7 @@ public function up(): void
$table->string('slug', 200)->unique(); $table->string('slug', 200)->unique();
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('status', 20)->default(ProductStatus::ACTIVE->value); $table->string('status', 20)->default(ProductStatus::ACTIVE->value);
$table->boolean('is_featured')->default(false);
$table->timestamp('created_at')->useCurrent(); $table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

View File

@ -21,7 +21,7 @@ public function run(): void
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'], 'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
'categories' => ['view', 'create', 'update', 'delete'], 'categories' => ['view', 'create', 'update', 'delete'],
'customers' => ['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'], 'stocks' => ['view'],
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'], 'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'], 'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
@ -124,6 +124,7 @@ public function run(): void
'products.update', 'products.update',
'products.delete', 'products.delete',
'products.toggle_status', 'products.toggle_status',
'products.toggle_featured',
'products.transfer_stock', 'products.transfer_stock',
'products.view_stock_mutations', 'products.view_stock_mutations',

View File

@ -1,13 +1,12 @@
import { createInertiaApp } from '@inertiajs/react'; import { FlashToast, PWAUpdateToast } from '@/components/notifications';
import { registerSW } from 'virtual:pwa-register';
import { FlashToast } from '@/components/notifications';
import { PWAUpdateToast } from '@/components/notifications';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout'; import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/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'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@ -26,6 +25,8 @@ createInertiaApp({
switch (true) { switch (true) {
case name === 'admin/manage/cutting/show': case name === 'admin/manage/cutting/show':
return null; return null;
case name === 'welcome':
return null;
case name.startsWith('auth/'): case name.startsWith('auth/'):
return AuthLayout; return AuthLayout;
case name.startsWith('settings/'): case name.startsWith('settings/'):

View File

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

View File

@ -32,6 +32,7 @@ import {
edit as productEdit, edit as productEdit,
index as productIndex, index as productIndex,
toggleStatus, toggleStatus,
toggleFeatured as productToggleFeatured,
approve as productApprove, approve as productApprove,
reject as productReject, reject as productReject,
resubmit as productResubmit, resubmit as productResubmit,
@ -63,6 +64,7 @@ type Props = {
name?: string; name?: string;
stock?: string; stock?: string;
category?: string; category?: string;
featured?: string;
}; };
}; };
@ -160,7 +162,8 @@ export default function ProductIndex({ products, categories, productNames, filte
filters.status || filters.status ||
filters.name || filters.name ||
filters.stock || filters.stock ||
filters.category, filters.category ||
filters.featured,
)} )}
onClear={clearFilters} onClear={clearFilters}
> >
@ -265,6 +268,25 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
</div> </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> </FilterPopover>
); );
@ -321,6 +343,7 @@ export default function ProductIndex({ products, categories, productNames, filte
onDelete={(p) => setDeleting(p)} onDelete={(p) => setDeleting(p)}
onReject={(p) => setRejecting(p)} onReject={(p) => setRejecting(p)}
toggleStatusUrl={(id) => toggleStatus.url(id)} toggleStatusUrl={(id) => toggleStatus.url(id)}
toggleFeaturedUrl={(id) => productToggleFeatured.url(id)}
approveUrl={(id) => productApprove.url(id)} approveUrl={(id) => productApprove.url(id)}
resubmitUrl={(id) => productResubmit.url(id)} resubmitUrl={(id) => productResubmit.url(id)}
/> />

View File

@ -42,6 +42,7 @@ export type ProductCardRowParams = {
onDelete: (product: Product) => void; onDelete: (product: Product) => void;
onReject: (product: Product) => void; onReject: (product: Product) => void;
toggleStatusUrl: (id: number) => string; toggleStatusUrl: (id: number) => string;
toggleFeaturedUrl: (id: number) => string;
approveUrl: (id: number) => string; approveUrl: (id: number) => string;
resubmitUrl: (id: number) => string; resubmitUrl: (id: number) => string;
}; };
@ -55,6 +56,7 @@ export function ProductCardRow({
onDelete, onDelete,
onReject, onReject,
toggleStatusUrl, toggleStatusUrl,
toggleFeaturedUrl,
approveUrl, approveUrl,
resubmitUrl, resubmitUrl,
}: ProductCardRowParams) { }: ProductCardRowParams) {
@ -173,6 +175,16 @@ export function ProductCardRow({
</span> </span>
)} )}
</div> </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> </div>
{/* Actions */} {/* 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::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-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}/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}/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'); Route::post('products/{product}/resubmit', [ProductController::class, 'resubmit'])->name('products.resubmit')->middleware('permission:products.update');