feat: add is_featured attribute and toggle functionality for products
This commit is contained in:
parent
d5f2a66a14
commit
1c9532199c
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
@ -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/'):
|
||||
|
||||
@ -32,6 +32,7 @@ export type Product = {
|
||||
slug: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
is_featured: boolean;
|
||||
rejection_reason: string | null;
|
||||
categories: {
|
||||
id: number;
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user