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\Models\Product;
use App\Services\Admin\Master\CategoryService; use App\Services\Admin\Master\CategoryService;
use App\Services\Admin\Master\Product\ProductService; use App\Services\Admin\Master\Product\ProductService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -124,4 +125,11 @@ public function resubmit(Product $product): RedirectResponse
return back(); 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\Http\Requests\PaginatedRequest;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Services\Admin\Master\RawMaterial\RawMaterialService; use App\Services\Admin\Master\RawMaterial\RawMaterialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -80,4 +81,11 @@ public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse
return back(); 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 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']) ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured'])
->with([ ->with(['categories:id,name'])
'categories:id,name', ->withCount('productVariants as variants_count')
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', ->selectRaw("{$stockSumQuery} as total_stock")
'productVariants.productPrices:id,variant_id,type,price', ->selectRaw("{$rejectSumQuery} as total_reject_stock")
]) ->selectRaw("{$retailSumQuery} as total_retail_stock")
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%") ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")
->orWhereHas('productVariants', fn ($vq) => $vq->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); $cq->where('categories.id', $categoryId);
})) }))
->when($filters['featured'] ?? null, fn ($q, $featured) => $q->where('is_featured', $featured === 'true')) ->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) use ($stockSumQuery) {
$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("{$stockSumQuery} = 0");
}) })
->when(($filters['stock'] ?? null) === 'low', function ($q) { ->when(($filters['stock'] ?? null) === 'low', function ($q) use ($stockSumQuery) {
$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'); $q->whereRaw("{$stockSumQuery} BETWEEN 1 AND 9");
}) })
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
}
$paginator->getCollection()->each(function ($product) { public function getVariants(Product $product): Collection
$product->productVariants->each(function ($variant) { {
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'); $media = $variant->getMedia('images');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); $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(); $variant->photo_conversion_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath('thumb')))->toArray();
}); });
});
return $paginator;
} }
public function store(array $data): Product 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 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']) ->select(['id', 'name', 'unit', 'is_active'])
->with([ ->withCount('rawMaterialPrices as variants_count')
'rawMaterialPrices:id,raw_material_id,variant,price,stock', ->selectRaw("{$stockSumQuery} as total_stock")
]) ->selectRaw("{$valueSumQuery} as total_value")
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%") ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")
->orWhereHas('rawMaterialPrices', fn ($vq) => $vq->where('variant', '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) { ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) {
$q->where('is_active', $filters['is_active'] === 'true'); $q->where('is_active', $filters['is_active'] === 'true');
}) })
->when(($filters['stock'] ?? null) === 'empty', function ($q) { ->when(($filters['stock'] ?? null) === 'empty', function ($q) use ($stockSumQuery) {
$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'); $q->whereRaw("{$stockSumQuery} = 0");
}) })
->when(($filters['stock'] ?? null) === 'low', function ($q) { ->when(($filters['stock'] ?? null) === 'low', function ($q) use ($stockSumQuery) {
$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'); $q->whereRaw("{$stockSumQuery} BETWEEN 0.0001 AND 9.9999");
}) })
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
}
$paginator->getCollection()->each(function ($rawMaterial) { public function getVariants(RawMaterial $rawMaterial): Collection
$rawMaterial->rawMaterialPrices->each(function ($price) { {
return $rawMaterial->rawMaterialPrices()
->select(['id', 'raw_material_id', 'variant', 'price', 'stock'])
->get()
->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('images'); $media = $price->getFirstMedia('images');
if ($media) { if ($media) {
$price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath()); $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; $price->photo_conversion_url = null;
} }
}); });
});
return $paginator;
} }
public function store(array $data): RawMaterial public function store(array $data): RawMaterial

View File

@ -38,7 +38,11 @@ export type Product = {
id: number; id: number;
name: string; 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 { function getStatusLabel(status: string): string {

View File

@ -1,7 +1,7 @@
import { Link, router } from '@inertiajs/react'; import { Link, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs'; import type { ImagePreviewItem } from '@/components/dialogs';
@ -36,6 +36,7 @@ import {
approve as productApprove, approve as productApprove,
reject as productReject, reject as productReject,
resubmit as productResubmit, resubmit as productResubmit,
variants as productVariants,
} from '@/routes/admin/master/products'; } from '@/routes/admin/master/products';
import { import {
destroy as variantDestroy, destroy as variantDestroy,
@ -76,7 +77,9 @@ export default function ProductIndex({ products, categories, productNames, filte
variant: ProductVariant; variant: ProductVariant;
} | null>(null); } | null>(null);
const [rejecting, setRejecting] = useState<Product | 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 = { const pagination = {
current_page: products.current_page, current_page: products.current_page,
@ -106,20 +109,43 @@ export default function ProductIndex({ products, categories, productNames, filte
[productNames], [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( const allPreviewItems: ImagePreviewItem[] = useMemo(
() => () =>
products.data.flatMap((product) => Object.entries(loadedVariants).flatMap(([productId, variants]) =>
(product.product_variants ?? []) variants
.filter((v) => v.photo_urls?.length > 0) .filter((v) => v.photo_urls?.length > 0)
.map((v) => ({ .map((v) => ({
id: `${product.id}-${v.id}`, id: `${productId}-${v.id}`,
src: v.photo_urls[0], src: v.photo_urls[0],
sources: v.photo_urls, sources: v.photo_urls,
title: v.name, title: v.name,
description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`, description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`,
})) }))
), ),
[products.data], [loadedVariants],
); );
const selectedCategory = useMemo( const selectedCategory = useMemo(
@ -313,7 +339,14 @@ export default function ProductIndex({ products, categories, productNames, filte
data={products.data} data={products.data}
getItemKey={(p) => p.id} getItemKey={(p) => p.id}
expandedKeys={expand.expandedKeys} 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} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
@ -351,6 +384,8 @@ export default function ProductIndex({ products, categories, productNames, filte
renderSubContent={(product) => ( renderSubContent={(product) => (
<VariantSubRow <VariantSubRow
product={product} product={product}
variants={loadedVariants[product.id] ?? []}
isLoading={loadingVariants[product.id] ?? false}
allPreviewItems={allPreviewItems} allPreviewItems={allPreviewItems}
onEditVariant={(p, v) => { onEditVariant={(p, v) => {
router.visit( router.visit(

View File

@ -62,16 +62,10 @@ export function ProductCardRow({
}: ProductCardRowParams) { }: ProductCardRowParams) {
const { can, hasRole } = useCan(); const { can, hasRole } = useCan();
const isVerifier = hasRole('developer') || hasRole('owner'); const isVerifier = hasRole('developer') || hasRole('owner');
const variants = product.product_variants ?? []; const variantCount = product.variants_count ?? 0;
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); const totalStock = product.total_stock ?? 0;
const totalReject = variants.reduce( const totalReject = product.total_reject_stock ?? 0;
(sum, v) => sum + (v.reject_stock ?? 0), const totalRetail = product.total_retail_stock ?? 0;
0,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
const totalAll = totalStock + totalReject + totalRetail; const totalAll = totalStock + totalReject + totalRetail;
const isPending = product.status === 'pending'; 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"> <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"> <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>
<span> <span>
Bagus:{' '} Bagus:{' '}

View File

@ -21,11 +21,15 @@ import { TransferStockDialog } from './transfer-stock-dialog';
export function VariantSubRow({ export function VariantSubRow({
product, product,
variants: loadedVariants,
isLoading,
allPreviewItems, allPreviewItems,
onEditVariant, onEditVariant,
onDeleteVariantClick, onDeleteVariantClick,
}: { }: {
product: Product; product: Product;
variants: ProductVariant[];
isLoading: boolean;
allPreviewItems: ImagePreviewItem[]; allPreviewItems: ImagePreviewItem[];
onEditVariant: (product: Product, variant: ProductVariant) => void; onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (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 isVerifier = hasRole('developer') || hasRole('owner');
const isPending = product.status === 'pending'; const isPending = product.status === 'pending';
const isRejected = product.status === 'rejected'; const isRejected = product.status === 'rejected';
const variants = product.product_variants ?? [];
const [transferVariant, setTransferVariant] = useState<{ const [transferVariant, setTransferVariant] = useState<{
product: Product; product: Product;
variant: ProductVariant; variant: ProductVariant;
@ -75,7 +78,16 @@ export function VariantSubRow({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <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> <TableRow>
<TableCell <TableCell
colSpan={canAnyAction ? 8 : 7} colSpan={canAnyAction ? 8 : 7}
@ -85,7 +97,7 @@ export function VariantSubRow({
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
variants.map((variant, index) => ( loadedVariants.map((variant, index) => (
<TableRow key={variant.id}> <TableRow key={variant.id}>
<TableCell className="text-center"> <TableCell className="text-center">
{index + 1} {index + 1}

View File

@ -13,7 +13,10 @@ export type RawMaterial = {
name: string; name: string;
unit: string; unit: string;
is_active: boolean; is_active: boolean;
raw_material_prices: RawMaterialVariant[]; variants_count: number;
total_stock: number;
total_value: number;
raw_material_prices?: RawMaterialVariant[];
}; };
export type RawMaterialVariantForEdit = { export type RawMaterialVariantForEdit = {

View File

@ -1,10 +1,10 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { FilterPopover } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } 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 { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -31,6 +31,7 @@ import {
edit as rawMaterialEdit, edit as rawMaterialEdit,
index as rawMaterialIndex, index as rawMaterialIndex,
toggleStatus, toggleStatus,
variants as rawMaterialVariants,
} from '@/routes/admin/master/raw-materials'; } from '@/routes/admin/master/raw-materials';
import { import {
destroy as variantDestroy destroy as variantDestroy
@ -62,7 +63,9 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
rawMaterial: RawMaterial; rawMaterial: RawMaterial;
variant: RawMaterialVariant; variant: RawMaterialVariant;
} | null>(null); } | 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 = { const pagination = {
current_page: rawMaterials.current_page, current_page: rawMaterials.current_page,
@ -92,19 +95,42 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
[rawMaterialNames], [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( const allPreviewItems: ImagePreviewItem[] = useMemo(
() => () =>
rawMaterials.data.flatMap((rm) => Object.entries(loadedVariants).flatMap(([rmId, variants]) =>
(rm.raw_material_prices ?? []) variants
.filter((v) => v.photo_url) .filter((v) => v.photo_url)
.map((v) => ({ .map((v) => ({
id: `${rm.id}-${v.id}`, id: `${rmId}-${v.id}`,
src: v.photo_url, src: v.photo_url,
title: v.variant, title: v.variant,
description: `Stok: ${v.formatted_stock}`, description: `Stok: ${v.formatted_stock}`,
})) }))
), ),
[rawMaterials.data], [loadedVariants],
); );
function handleDelete() { function handleDelete() {
@ -230,7 +256,16 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
data={rawMaterials.data} data={rawMaterials.data}
getItemKey={(rm) => rm.id} getItemKey={(rm) => rm.id}
expandedKeys={expand.expandedKeys} 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} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
@ -264,6 +299,8 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
renderSubContent={(rawMaterial) => ( renderSubContent={(rawMaterial) => (
<RawMaterialVariantSubRow <RawMaterialVariantSubRow
rawMaterial={rawMaterial} rawMaterial={rawMaterial}
variants={loadedVariants[rawMaterial.id] ?? []}
isLoading={loadingVariants[rawMaterial.id] ?? false}
allPreviewItems={allPreviewItems} allPreviewItems={allPreviewItems}
/> />
)} )}

View File

@ -28,15 +28,9 @@ export function RawMaterialCardRow({
toggleStatusUrl, toggleStatusUrl,
}: RawMaterialCardRowParams) { }: RawMaterialCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const variants = rawMaterial.raw_material_prices ?? []; const variantCount = rawMaterial.variants_count ?? 0;
const totalStock = variants.reduce( const totalStock = rawMaterial.total_stock ?? 0;
(sum, v) => sum + (Number(v.stock) || 0), const totalValue = rawMaterial.total_value ?? 0;
0,
);
const totalValue = variants.reduce(
(sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0),
0,
);
return ( return (
<Card className="overflow-hidden"> <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"> <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"> <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>
<span> <span>
Total Stok:{' '} Total Stok:{' '}

View File

@ -1,10 +1,10 @@
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { RowActions } from '@/components/data-display';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs'; import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display';
import { import {
Table, Table,
TableBody, TableBody,
@ -24,13 +24,16 @@ import type { RawMaterial, RawMaterialVariant } from '../columns';
export function RawMaterialVariantSubRow({ export function RawMaterialVariantSubRow({
rawMaterial, rawMaterial,
variants: loadedVariants,
isLoading,
allPreviewItems, allPreviewItems,
}: { }: {
rawMaterial: RawMaterial; rawMaterial: RawMaterial;
variants: RawMaterialVariant[];
isLoading: boolean;
allPreviewItems: ImagePreviewItem[]; allPreviewItems: ImagePreviewItem[];
}) { }) {
const { can } = useCan(); const { can } = useCan();
const variants = rawMaterial.raw_material_prices ?? [];
const [deletingVariant, setDeletingVariant] = const [deletingVariant, setDeletingVariant] =
useState<RawMaterialVariant | null>(null); useState<RawMaterialVariant | null>(null);
@ -72,7 +75,16 @@ export function RawMaterialVariantSubRow({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <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> <TableRow>
<TableCell <TableCell
colSpan={canAnyAction ? 6 : 5} colSpan={canAnyAction ? 6 : 5}
@ -82,7 +94,7 @@ export function RawMaterialVariantSubRow({
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
variants.map((variant, index) => ( loadedVariants.map((variant, index) => (
<TableRow key={variant.id}> <TableRow key={variant.id}>
<TableCell className="text-center"> <TableCell className="text-center">
{index + 1} {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}/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');
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::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::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'); Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update')->middleware('permission:products.update');