From c2354440e3b00c55bd5d2cf2da292abc8743e5c0 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 14 Aug 2026 07:17:34 +0700 Subject: [PATCH] feat: implement lazy loading for restock items, optimize data fetching in Restock service and controller --- .../Admin/Manage/RestockController.php | 8 ++++ app/Services/Admin/Manage/RestockService.php | 28 +++++++---- docs/2026-08-14-lazy-loading-card-table.md | 12 ++++- .../js/pages/admin/manage/restock/columns.tsx | 5 +- .../js/pages/admin/manage/restock/index.tsx | 47 +++++++++++++++++-- .../admin/manage/restock/restock-card.tsx | 17 ++----- .../admin/manage/restock/restock-sub-row.tsx | 25 ++++++++-- routes/web.php | 1 + 8 files changed, 109 insertions(+), 34 deletions(-) diff --git a/app/Http/Controllers/Admin/Manage/RestockController.php b/app/Http/Controllers/Admin/Manage/RestockController.php index 32a387f..0132c2f 100644 --- a/app/Http/Controllers/Admin/Manage/RestockController.php +++ b/app/Http/Controllers/Admin/Manage/RestockController.php @@ -8,6 +8,7 @@ use App\Models\Restock; use App\Services\Admin\Manage\RestockService; use App\Services\Admin\Master\Product\ProductVariantService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -72,4 +73,11 @@ public function destroy(Restock $restock): RedirectResponse 'admin.manage.restocks.index' ); } + + public function items(Restock $restock): JsonResponse + { + return response()->json([ + 'items' => $this->service->getItems($restock), + ]); + } } diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php index 40b82f7..432f0aa 100644 --- a/app/Services/Admin/Manage/RestockService.php +++ b/app/Services/Admin/Manage/RestockService.php @@ -25,17 +25,19 @@ public function __construct( public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator { + $itemsCountQuery = '(SELECT COUNT(*) FROM restock_items WHERE restock_items.restock_id = restocks.id AND restock_items.deleted_at IS NULL)'; + $totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM restock_items WHERE restock_items.restock_id = restocks.id AND restock_items.deleted_at IS NULL)'; + $productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM restock_items ri JOIN product_variants pv ON pv.id = ri.product_variant_id JOIN products p ON p.id = pv.product_id WHERE ri.restock_id = restocks.id AND ri.deleted_at IS NULL)'; + $paginator = Restock::query() ->select(['id', 'created_by_id', 'total', 'notes', 'stock_type', 'created_at']) ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', - 'restockItems' => fn ($q) => $q - ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']) - ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'), - 'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', - 'restockItems.productVariant.product:id,name', ]) + ->selectRaw("{$itemsCountQuery} as items_count") + ->selectRaw("{$totalQtyQuery} as total_qty") + ->selectRaw("{$productNamesQuery} as product_names") ->when($search, function ($q) use ($search) { $q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) ->orWhere('notes', 'like', "%{$search}%"); @@ -43,8 +45,17 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->orderBy($sort, $direction) ->paginate($perPage); - $paginator->getCollection()->each(function (Restock $restock) { - $restock->restockItems->each(function (RestockItem $item) { + return $paginator; + } + + public function getItems(Restock $restock): \Illuminate\Support\Collection + { + return $restock->restockItems() + ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']) + ->with(['productVariant:id,product_id,name', 'productVariant.product:id,name']) + ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)') + ->get() + ->each(function (RestockItem $item) { if (! $item->productVariant) { return; } @@ -57,9 +68,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : null; }); - }); - - return $paginator; } public function store(array $data): Restock diff --git a/docs/2026-08-14-lazy-loading-card-table.md b/docs/2026-08-14-lazy-loading-card-table.md index aab260a..77adc81 100644 --- a/docs/2026-08-14-lazy-loading-card-table.md +++ b/docs/2026-08-14-lazy-loading-card-table.md @@ -4,7 +4,7 @@ ## Date 2026-08-14 ## Goal -Optimize page load performance for raw materials, products, purchases, cutting, and transaction list pages by implementing lazy loading for child/variant data in card-based layouts. +Optimize page load performance for raw materials, products, purchases, cutting, transaction, and restock list pages by implementing lazy loading for child/variant data in card-based layouts. ## Problem Pages with 100+ records loaded all child data eagerly (variants, items, materials), causing slow initial page loads. @@ -19,6 +19,15 @@ ## Solution Pattern ## Files Modified +### Restock (this session) +- `app/Services/Admin/Manage/RestockService.php` - `paginated()` no longer eager loads `restockItems`; added `getItems()` method +- `app/Http/Controllers/Admin/Manage/RestockController.php` - added `items()` method +- `routes/web.php` - added `GET restocks/{restock}/items` route +- `resources/js/pages/admin/manage/restock/columns.tsx` - `Restock` type: `items_count`, `total_qty`, `product_names` +- `resources/js/pages/admin/manage/restock/restock-card.tsx` - uses summary data from props +- `resources/js/pages/admin/manage/restock/restock-sub-row.tsx` - accepts `items` & `isLoading` props +- `resources/js/pages/admin/manage/restock/index.tsx` - lazy loading with `fetchItems()` + ### Transaction (this session) - `app/Services/Admin/Manage/TransactionService.php` - `paginated()` no longer eager loads `orderItems`; added `getItems()` method - `app/Http/Controllers/Admin/Manage/TransactionController.php` - added `items()` method @@ -56,6 +65,7 @@ ## Routes Added Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view'); Route::get('cuttings/{cutting}/materials', [CuttingController::class, 'materials'])->name('cuttings.materials')->middleware('permission:cuttings.view'); Route::get('transactions/{transaction}/items', [TransactionController::class, 'items'])->name('transactions.items')->middleware('permission:orders.view'); +Route::get('restocks/{restock}/items', [RestockController::class, 'items'])->name('restocks.items')->middleware('permission:restocks.view'); ``` ## Key Implementation Details diff --git a/resources/js/pages/admin/manage/restock/columns.tsx b/resources/js/pages/admin/manage/restock/columns.tsx index 7e5f594..35927cc 100644 --- a/resources/js/pages/admin/manage/restock/columns.tsx +++ b/resources/js/pages/admin/manage/restock/columns.tsx @@ -25,13 +25,16 @@ export type Restock = { notes: string | null; stock_type: RestockStockType; created_at: string; + items_count: number; + total_qty: number; + product_names: string | null; created_by: { id: number; user_profile: { full_name: string; }; }; - restock_items: RestockItem[]; + restock_items?: RestockItem[]; }; export type RestockForEdit = { diff --git a/resources/js/pages/admin/manage/restock/index.tsx b/resources/js/pages/admin/manage/restock/index.tsx index 08b3c11..502fe2a 100644 --- a/resources/js/pages/admin/manage/restock/index.tsx +++ b/resources/js/pages/admin/manage/restock/index.tsx @@ -1,6 +1,6 @@ import { Head, Link, router } from '@inertiajs/react'; import { Plus } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { CardTable } from '@/components/data-display'; import { DeleteConfirmDialog } from '@/components/dialogs'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; @@ -13,8 +13,9 @@ import { create as restockCreate, index as restockIndex, edit as restockEdit, + items as restockItems, } from '@/routes/admin/manage/restocks'; -import type { Restock } from './columns'; +import type { Restock, RestockItem } from './columns'; import { RestockCardRow } from './restock-card'; import { RestockItemSubRow } from './restock-sub-row'; @@ -31,7 +32,9 @@ type Props = { export default function RestockIndex({ restocks }: Props) { const { can } = useCan(); const [deleting, setDeleting] = useState(null); - const expand = useCardTableExpand(true); + const [loadedItems, setLoadedItems] = useState>({}); + const [loadingItems, setLoadingItems] = useState>({}); + const expand = useCardTableExpand(false); const pagination = { current_page: restocks.current_page, @@ -50,6 +53,29 @@ export default function RestockIndex({ restocks }: Props) { pagination, }); + const fetchItems = useCallback((restock: Restock) => { + if (loadedItems[restock.id] || loadingItems[restock.id]) { + return; + } + + setLoadingItems((prev) => ({ ...prev, [restock.id]: true })); + + fetch(restockItems.url(restock.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedItems((prev) => ({ + ...prev, + [restock.id]: data.items ?? [], + })); + }) + .catch(() => { + setLoadedItems((prev) => ({ ...prev, [restock.id]: [] })); + }) + .finally(() => { + setLoadingItems((prev) => ({ ...prev, [restock.id]: false })); + }); + }, [loadedItems, loadingItems]); + function handleDelete() { if (!deleting) { return; @@ -83,7 +109,14 @@ export default function RestockIndex({ restocks }: Props) { data={restocks.data} getItemKey={(r) => r.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const r = restocks.data.find((item) => item.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + if (r && !isCurrentlyExpanded) { + fetchItems(r); + } + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -113,7 +146,11 @@ export default function RestockIndex({ restocks }: Props) { /> )} renderSubContent={(restock) => ( - + )} /> diff --git a/resources/js/pages/admin/manage/restock/restock-card.tsx b/resources/js/pages/admin/manage/restock/restock-card.tsx index 1a3802c..c7b13de 100644 --- a/resources/js/pages/admin/manage/restock/restock-card.tsx +++ b/resources/js/pages/admin/manage/restock/restock-card.tsx @@ -40,19 +40,10 @@ export function RestockCardRow({ onDelete, }: RestockCardRowParams) { const { can } = useCan(); - const items = restock.restock_items ?? []; - const variantCount = items.length; - const productNames = [ - ...new Set( - items - .map((item) => item.product_variant?.product?.name) - .filter(Boolean), - ), - ]; - const totalQty = items.reduce( - (sum, item) => sum + Number(item.quantity), - 0, - ); + const variantCount = restock.items_count ?? 0; + const totalQty = restock.total_qty ?? 0; + const productNamesStr = restock.product_names ?? ''; + const productNames = productNamesStr ? productNamesStr.split(', ') : []; const stockTypeConfig = STOCK_TYPE_CONFIG[restock.stock_type] ?? STOCK_TYPE_CONFIG.good; diff --git a/resources/js/pages/admin/manage/restock/restock-sub-row.tsx b/resources/js/pages/admin/manage/restock/restock-sub-row.tsx index 4b8d69e..5bff91e 100644 --- a/resources/js/pages/admin/manage/restock/restock-sub-row.tsx +++ b/resources/js/pages/admin/manage/restock/restock-sub-row.tsx @@ -10,10 +10,18 @@ import { } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; -import type { Restock } from './columns'; +import type { Restock, RestockItem } from './columns'; -export function RestockItemSubRow({ restock }: { restock: Restock }) { - const items = restock.restock_items ?? []; +export function RestockItemSubRow({ + restock, + items: loadedItems, + isLoading, +}: { + restock: Restock; + items: RestockItem[]; + isLoading: boolean; +}) { + const items = loadedItems ?? []; const groupedByProduct = items.reduce( (acc, item) => { @@ -50,7 +58,16 @@ acc[name] = []; - {items.length === 0 ? ( + {isLoading ? ( + + + Memuat item... + + + ) : items.length === 0 ? ( name('transactions.items')->middleware('permission:orders.view'); Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restocks.view|restocks.create|restocks.update|restocks.delete'); + Route::get('restocks/{restock}/items', [RestockController::class, 'items'])->name('restocks.items')->middleware('permission:restocks.view'); }); Route::prefix('finance')->name('admin.finance.')->group(function () {