From ce901c2c8bdf6a4bf929246c7b2d4c763eab7235 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 14 Aug 2026 06:48:28 +0700 Subject: [PATCH] feat: add variants endpoint and update product/raw material models for variant handling --- .../Master/Product/ProductController.php | 8 +++ .../RawMaterial/RawMaterialController.php | 8 +++ .../Admin/Master/Product/ProductService.php | 37 +++++++------ .../Master/RawMaterial/RawMaterialService.php | 31 ++++++----- .../js/pages/admin/master/product/columns.tsx | 6 ++- .../js/pages/admin/master/product/index.tsx | 49 ++++++++++++++--- .../admin/master/product/product-card.tsx | 16 ++---- .../admin/master/product/variant/sub-row.tsx | 18 +++++-- .../admin/master/raw-material/columns.tsx | 5 +- .../pages/admin/master/raw-material/index.tsx | 53 ++++++++++++++++--- .../master/raw-material/raw-material-card.tsx | 14 ++--- .../master/raw-material/variant/sub-row.tsx | 20 +++++-- routes/web.php | 1 + 13 files changed, 193 insertions(+), 73 deletions(-) diff --git a/app/Http/Controllers/Admin/Master/Product/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php index 3ffbaa8..c7b6245 100644 --- a/app/Http/Controllers/Admin/Master/Product/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -8,6 +8,7 @@ use App\Models\Product; use App\Services\Admin\Master\CategoryService; use App\Services\Admin\Master\Product\ProductService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -124,4 +125,11 @@ public function resubmit(Product $product): RedirectResponse return back(); } + + public function variants(Product $product): JsonResponse + { + return response()->json([ + 'variants' => $this->service->getVariants($product), + ]); + } } diff --git a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php index dbfbfb8..eb09198 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php @@ -7,6 +7,7 @@ use App\Http\Requests\PaginatedRequest; use App\Models\RawMaterial; use App\Services\Admin\Master\RawMaterial\RawMaterialService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -80,4 +81,11 @@ public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse return back(); } + + public function variants(RawMaterial $rawMaterial): JsonResponse + { + return response()->json([ + 'variants' => $this->service->getVariants($rawMaterial), + ]); + } } diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index b2239cc..8506736 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -34,13 +34,17 @@ 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() + $stockSumQuery = '(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL)'; + $rejectSumQuery = '(SELECT IFNULL(SUM(reject_stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL)'; + $retailSumQuery = '(SELECT IFNULL(SUM(retail_stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL)'; + + return Product::query() ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured']) - ->with([ - 'categories:id,name', - 'productVariants:id,product_id,name,stock,reject_stock,retail_stock', - 'productVariants.productPrices:id,variant_id,type,price', - ]) + ->with(['categories:id,name']) + ->withCount('productVariants as variants_count') + ->selectRaw("{$stockSumQuery} as total_stock") + ->selectRaw("{$rejectSumQuery} as total_reject_stock") + ->selectRaw("{$retailSumQuery} as total_retail_stock") ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%") ->orWhereHas('productVariants', fn ($vq) => $vq->where('name', 'like', "%{$search}%")) ) @@ -50,24 +54,27 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = $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'); + ->when(($filters['stock'] ?? null) === 'empty', function ($q) use ($stockSumQuery) { + $q->whereRaw("{$stockSumQuery} = 0"); }) - ->when(($filters['stock'] ?? null) === 'low', 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) BETWEEN 1 AND 9'); + ->when(($filters['stock'] ?? null) === 'low', function ($q) use ($stockSumQuery) { + $q->whereRaw("{$stockSumQuery} BETWEEN 1 AND 9"); }) ->orderBy($sort, $direction) ->paginate($perPage); + } - $paginator->getCollection()->each(function ($product) { - $product->productVariants->each(function ($variant) { + public function getVariants(Product $product): Collection + { + return $product->productVariants() + ->select(['id', 'product_id', 'name', 'stock', 'reject_stock', 'retail_stock']) + ->with(['productPrices:id,variant_id,type,price']) + ->get() + ->each(function (ProductVariant $variant) { $media = $variant->getMedia('images'); $variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); $variant->photo_conversion_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath('thumb')))->toArray(); }); - }); - - return $paginator; } public function store(array $data): Product diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php index 5aa3b11..d05364d 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php @@ -27,11 +27,14 @@ public function getNames(): Collection public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { - $paginator = RawMaterial::query() + $stockSumQuery = '(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL)'; + $valueSumQuery = '(SELECT IFNULL(SUM(price * stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL)'; + + return RawMaterial::query() ->select(['id', 'name', 'unit', 'is_active']) - ->with([ - 'rawMaterialPrices:id,raw_material_id,variant,price,stock', - ]) + ->withCount('rawMaterialPrices as variants_count') + ->selectRaw("{$stockSumQuery} as total_stock") + ->selectRaw("{$valueSumQuery} as total_value") ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%") ->orWhereHas('rawMaterialPrices', fn ($vq) => $vq->where('variant', 'like', "%{$search}%")) ) @@ -39,17 +42,22 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) { $q->where('is_active', $filters['is_active'] === 'true'); }) - ->when(($filters['stock'] ?? null) === 'empty', function ($q) { - $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) = 0'); + ->when(($filters['stock'] ?? null) === 'empty', function ($q) use ($stockSumQuery) { + $q->whereRaw("{$stockSumQuery} = 0"); }) - ->when(($filters['stock'] ?? null) === 'low', function ($q) { - $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) BETWEEN 0.0001 AND 9.9999'); + ->when(($filters['stock'] ?? null) === 'low', function ($q) use ($stockSumQuery) { + $q->whereRaw("{$stockSumQuery} BETWEEN 0.0001 AND 9.9999"); }) ->orderBy($sort, $direction) ->paginate($perPage); + } - $paginator->getCollection()->each(function ($rawMaterial) { - $rawMaterial->rawMaterialPrices->each(function ($price) { + public function getVariants(RawMaterial $rawMaterial): Collection + { + return $rawMaterial->rawMaterialPrices() + ->select(['id', 'raw_material_id', 'variant', 'price', 'stock']) + ->get() + ->each(function (RawMaterialPrice $price) { $media = $price->getFirstMedia('images'); if ($media) { $price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath()); @@ -59,9 +67,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = $price->photo_conversion_url = null; } }); - }); - - return $paginator; } public function store(array $data): RawMaterial diff --git a/resources/js/pages/admin/master/product/columns.tsx b/resources/js/pages/admin/master/product/columns.tsx index 509035d..2bfec9c 100644 --- a/resources/js/pages/admin/master/product/columns.tsx +++ b/resources/js/pages/admin/master/product/columns.tsx @@ -38,7 +38,11 @@ export type Product = { id: number; name: string; }[]; - product_variants: ProductVariant[]; + variants_count: number; + total_stock: number; + total_reject_stock: number; + total_retail_stock: number; + product_variants?: ProductVariant[]; }; function getStatusLabel(status: string): string { diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index 90aab3b..aca7ccb 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -1,7 +1,7 @@ import { Link, router } from '@inertiajs/react'; import { Head } from '@inertiajs/react'; import { Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { CardTable } from '@/components/data-display'; import { DeleteConfirmDialog } from '@/components/dialogs'; import type { ImagePreviewItem } from '@/components/dialogs'; @@ -36,6 +36,7 @@ import { approve as productApprove, reject as productReject, resubmit as productResubmit, + variants as productVariants, } from '@/routes/admin/master/products'; import { destroy as variantDestroy, @@ -76,7 +77,9 @@ export default function ProductIndex({ products, categories, productNames, filte variant: ProductVariant; } | null>(null); const [rejecting, setRejecting] = useState(null); - const expand = useCardTableExpand(true); + const [loadedVariants, setLoadedVariants] = useState>({}); + const [loadingVariants, setLoadingVariants] = useState>({}); + const expand = useCardTableExpand(false); const pagination = { current_page: products.current_page, @@ -106,20 +109,43 @@ export default function ProductIndex({ products, categories, productNames, filte [productNames], ); + const fetchVariants = useCallback((product: Product) => { + if (loadedVariants[product.id] || loadingVariants[product.id]) { + return; + } + + setLoadingVariants((prev) => ({ ...prev, [product.id]: true })); + + fetch(productVariants.url(product.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedVariants((prev) => ({ + ...prev, + [product.id]: data.variants ?? [], + })); + }) + .catch(() => { + setLoadedVariants((prev) => ({ ...prev, [product.id]: [] })); + }) + .finally(() => { + setLoadingVariants((prev) => ({ ...prev, [product.id]: false })); + }); + }, [loadedVariants, loadingVariants]); + const allPreviewItems: ImagePreviewItem[] = useMemo( () => - products.data.flatMap((product) => - (product.product_variants ?? []) + Object.entries(loadedVariants).flatMap(([productId, variants]) => + variants .filter((v) => v.photo_urls?.length > 0) .map((v) => ({ - id: `${product.id}-${v.id}`, + id: `${productId}-${v.id}`, src: v.photo_urls[0], sources: v.photo_urls, title: v.name, description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`, })) ), - [products.data], + [loadedVariants], ); const selectedCategory = useMemo( @@ -313,7 +339,14 @@ export default function ProductIndex({ products, categories, productNames, filte data={products.data} getItemKey={(p) => p.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const p = products.data.find((r) => r.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + if (p && !isCurrentlyExpanded) { + fetchVariants(p); + } + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -351,6 +384,8 @@ export default function ProductIndex({ products, categories, productNames, filte renderSubContent={(product) => ( { router.visit( diff --git a/resources/js/pages/admin/master/product/product-card.tsx b/resources/js/pages/admin/master/product/product-card.tsx index 5855414..fb9a12d 100644 --- a/resources/js/pages/admin/master/product/product-card.tsx +++ b/resources/js/pages/admin/master/product/product-card.tsx @@ -62,16 +62,10 @@ export function ProductCardRow({ }: ProductCardRowParams) { const { can, hasRole } = useCan(); const isVerifier = hasRole('developer') || hasRole('owner'); - const variants = product.product_variants ?? []; - const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); - const totalReject = variants.reduce( - (sum, v) => sum + (v.reject_stock ?? 0), - 0, - ); - const totalRetail = variants.reduce( - (sum, v) => sum + (v.retail_stock ?? 0), - 0, - ); + const variantCount = product.variants_count ?? 0; + const totalStock = product.total_stock ?? 0; + const totalReject = product.total_reject_stock ?? 0; + const totalRetail = product.total_retail_stock ?? 0; const totalAll = totalStock + totalReject + totalRetail; const isPending = product.status === 'pending'; @@ -132,7 +126,7 @@ export function ProductCardRow({
- {variants.length} varian + {variantCount} varian Bagus:{' '} diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx index dcfea44..9b0cbf1 100644 --- a/resources/js/pages/admin/master/product/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx @@ -21,11 +21,15 @@ import { TransferStockDialog } from './transfer-stock-dialog'; export function VariantSubRow({ product, + variants: loadedVariants, + isLoading, allPreviewItems, onEditVariant, onDeleteVariantClick, }: { product: Product; + variants: ProductVariant[]; + isLoading: boolean; allPreviewItems: ImagePreviewItem[]; onEditVariant: (product: Product, variant: ProductVariant) => void; onDeleteVariantClick: (product: Product, variant: ProductVariant) => void; @@ -34,7 +38,6 @@ export function VariantSubRow({ const isVerifier = hasRole('developer') || hasRole('owner'); const isPending = product.status === 'pending'; const isRejected = product.status === 'rejected'; - const variants = product.product_variants ?? []; const [transferVariant, setTransferVariant] = useState<{ product: Product; variant: ProductVariant; @@ -75,7 +78,16 @@ export function VariantSubRow({ - {variants.length === 0 ? ( + {isLoading ? ( + + + Memuat varian... + + + ) : loadedVariants.length === 0 ? ( ) : ( - variants.map((variant, index) => ( + loadedVariants.map((variant, index) => ( {index + 1} diff --git a/resources/js/pages/admin/master/raw-material/columns.tsx b/resources/js/pages/admin/master/raw-material/columns.tsx index d9c4873..6dcaa0c 100644 --- a/resources/js/pages/admin/master/raw-material/columns.tsx +++ b/resources/js/pages/admin/master/raw-material/columns.tsx @@ -13,7 +13,10 @@ export type RawMaterial = { name: string; unit: string; is_active: boolean; - raw_material_prices: RawMaterialVariant[]; + variants_count: number; + total_stock: number; + total_value: number; + raw_material_prices?: RawMaterialVariant[]; }; export type RawMaterialVariantForEdit = { diff --git a/resources/js/pages/admin/master/raw-material/index.tsx b/resources/js/pages/admin/master/raw-material/index.tsx index d9207f5..21f821e 100644 --- a/resources/js/pages/admin/master/raw-material/index.tsx +++ b/resources/js/pages/admin/master/raw-material/index.tsx @@ -1,10 +1,10 @@ import { Head, Link, router } from '@inertiajs/react'; import { Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { CardTable } from '@/components/data-display'; +import { FilterPopover } from '@/components/data-display'; import { DeleteConfirmDialog } from '@/components/dialogs'; import type { ImagePreviewItem } from '@/components/dialogs'; -import { FilterPopover } from '@/components/data-display'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { PageHeader } from '@/components/layout'; import { Button } from '@/components/ui/button'; @@ -31,6 +31,7 @@ import { edit as rawMaterialEdit, index as rawMaterialIndex, toggleStatus, + variants as rawMaterialVariants, } from '@/routes/admin/master/raw-materials'; import { destroy as variantDestroy @@ -62,7 +63,9 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte rawMaterial: RawMaterial; variant: RawMaterialVariant; } | null>(null); - const expand = useCardTableExpand(true); + const [loadedVariants, setLoadedVariants] = useState>({}); + const [loadingVariants, setLoadingVariants] = useState>({}); + const expand = useCardTableExpand(false); const pagination = { current_page: rawMaterials.current_page, @@ -92,19 +95,42 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte [rawMaterialNames], ); + const fetchVariants = useCallback((rawMaterial: RawMaterial) => { + if (loadedVariants[rawMaterial.id] || loadingVariants[rawMaterial.id]) { + return; + } + + setLoadingVariants((prev) => ({ ...prev, [rawMaterial.id]: true })); + + fetch(rawMaterialVariants.url(rawMaterial.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedVariants((prev) => ({ + ...prev, + [rawMaterial.id]: data.variants ?? [], + })); + }) + .catch(() => { + setLoadedVariants((prev) => ({ ...prev, [rawMaterial.id]: [] })); + }) + .finally(() => { + setLoadingVariants((prev) => ({ ...prev, [rawMaterial.id]: false })); + }); + }, [loadedVariants, loadingVariants]); + const allPreviewItems: ImagePreviewItem[] = useMemo( () => - rawMaterials.data.flatMap((rm) => - (rm.raw_material_prices ?? []) + Object.entries(loadedVariants).flatMap(([rmId, variants]) => + variants .filter((v) => v.photo_url) .map((v) => ({ - id: `${rm.id}-${v.id}`, + id: `${rmId}-${v.id}`, src: v.photo_url, title: v.variant, description: `Stok: ${v.formatted_stock}`, })) ), - [rawMaterials.data], + [loadedVariants], ); function handleDelete() { @@ -230,7 +256,16 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte data={rawMaterials.data} getItemKey={(rm) => rm.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const rm = rawMaterials.data.find((r) => r.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + + if (rm && !isCurrentlyExpanded) { + fetchVariants(rm); + } + + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -264,6 +299,8 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte renderSubContent={(rawMaterial) => ( )} diff --git a/resources/js/pages/admin/master/raw-material/raw-material-card.tsx b/resources/js/pages/admin/master/raw-material/raw-material-card.tsx index a6a26e5..21753e7 100644 --- a/resources/js/pages/admin/master/raw-material/raw-material-card.tsx +++ b/resources/js/pages/admin/master/raw-material/raw-material-card.tsx @@ -28,15 +28,9 @@ export function RawMaterialCardRow({ toggleStatusUrl, }: RawMaterialCardRowParams) { const { can } = useCan(); - const variants = rawMaterial.raw_material_prices ?? []; - const totalStock = variants.reduce( - (sum, v) => sum + (Number(v.stock) || 0), - 0, - ); - const totalValue = variants.reduce( - (sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), - 0, - ); + const variantCount = rawMaterial.variants_count ?? 0; + const totalStock = rawMaterial.total_stock ?? 0; + const totalValue = rawMaterial.total_value ?? 0; return ( @@ -65,7 +59,7 @@ export function RawMaterialCardRow({
- {variants.length} varian + {variantCount} varian Total Stok:{' '} diff --git a/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx index 16aa7b4..170f6a1 100644 --- a/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx @@ -1,10 +1,10 @@ import { router } from '@inertiajs/react'; import { Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; +import { RowActions } from '@/components/data-display'; import { ConfirmDialog } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs'; import type { ImagePreviewItem } from '@/components/dialogs'; -import { RowActions } from '@/components/data-display'; import { Table, TableBody, @@ -24,13 +24,16 @@ import type { RawMaterial, RawMaterialVariant } from '../columns'; export function RawMaterialVariantSubRow({ rawMaterial, + variants: loadedVariants, + isLoading, allPreviewItems, }: { rawMaterial: RawMaterial; + variants: RawMaterialVariant[]; + isLoading: boolean; allPreviewItems: ImagePreviewItem[]; }) { const { can } = useCan(); - const variants = rawMaterial.raw_material_prices ?? []; const [deletingVariant, setDeletingVariant] = useState(null); @@ -72,7 +75,16 @@ export function RawMaterialVariantSubRow({ - {variants.length === 0 ? ( + {isLoading ? ( + + + Memuat varian... + + + ) : loadedVariants.length === 0 ? ( ) : ( - variants.map((variant, index) => ( + loadedVariants.map((variant, index) => ( {index + 1} diff --git a/routes/web.php b/routes/web.php index e00f3f4..71ed118 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,6 +45,7 @@ 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'); + Route::get('products/{product}/variants', [ProductController::class, 'variants'])->name('products.variants')->middleware('permission:products.view'); Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy')->middleware('permission:products.delete'); Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit')->middleware('permission:products.update'); Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update')->middleware('permission:products.update');