feat: add variants endpoint and update product/raw material models for variant handling

This commit is contained in:
Yoga Pangestu 2026-08-14 06:48:28 +07:00
parent ff638cf9d1
commit ce901c2c8b
13 changed files with 193 additions and 73 deletions

View File

@ -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),
]);
}
}

View File

@ -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),
]);
}
}

View File

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

View File

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

View File

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

View File

@ -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<Product | null>(null);
const expand = useCardTableExpand(true);
const [loadedVariants, setLoadedVariants] = useState<Record<number, ProductVariant[]>>({});
const [loadingVariants, setLoadingVariants] = useState<Record<number, boolean>>({});
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) => (
<VariantSubRow
product={product}
variants={loadedVariants[product.id] ?? []}
isLoading={loadingVariants[product.id] ?? false}
allPreviewItems={allPreviewItems}
onEditVariant={(p, v) => {
router.visit(

View File

@ -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({
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{variants.length} varian
{variantCount} varian
</span>
<span>
Bagus:{' '}

View File

@ -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({
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
{isLoading ? (
<TableRow>
<TableCell
colSpan={canAnyAction ? 8 : 7}
className="text-center text-muted-foreground"
>
Memuat varian...
</TableCell>
</TableRow>
) : loadedVariants.length === 0 ? (
<TableRow>
<TableCell
colSpan={canAnyAction ? 8 : 7}
@ -85,7 +97,7 @@ export function VariantSubRow({
</TableCell>
</TableRow>
) : (
variants.map((variant, index) => (
loadedVariants.map((variant, index) => (
<TableRow key={variant.id}>
<TableCell className="text-center">
{index + 1}

View File

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

View File

@ -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<Record<number, RawMaterialVariant[]>>({});
const [loadingVariants, setLoadingVariants] = useState<Record<number, boolean>>({});
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) => (
<RawMaterialVariantSubRow
rawMaterial={rawMaterial}
variants={loadedVariants[rawMaterial.id] ?? []}
isLoading={loadingVariants[rawMaterial.id] ?? false}
allPreviewItems={allPreviewItems}
/>
)}

View File

@ -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 (
<Card className="overflow-hidden">
@ -65,7 +59,7 @@ export function RawMaterialCardRow({
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{variants.length} varian
{variantCount} varian
</span>
<span>
Total Stok:{' '}

View File

@ -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<RawMaterialVariant | null>(null);
@ -72,7 +75,16 @@ export function RawMaterialVariantSubRow({
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
{isLoading ? (
<TableRow>
<TableCell
colSpan={canAnyAction ? 6 : 5}
className="text-center text-muted-foreground"
>
Memuat varian...
</TableCell>
</TableRow>
) : loadedVariants.length === 0 ? (
<TableRow>
<TableCell
colSpan={canAnyAction ? 6 : 5}
@ -82,7 +94,7 @@ export function RawMaterialVariantSubRow({
</TableCell>
</TableRow>
) : (
variants.map((variant, index) => (
loadedVariants.map((variant, index) => (
<TableRow key={variant.id}>
<TableCell className="text-center">
{index + 1}

View File

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