From 910be15abc63897117647edba0347bc2314edd78 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 14 Aug 2026 07:02:51 +0700 Subject: [PATCH] feat: implement lazy loading for cutting materials and combinations, optimize data fetching in Cutting service and controller --- .../Admin/Manage/CuttingController.php | 9 + app/Services/Admin/Manage/CuttingService.php | 57 ++- docs/2026-08-14-lazy-loading-card-table.md | 24 +- .../js/pages/admin/manage/cutting/columns.tsx | 43 +- .../admin/manage/cutting/cutting-card.tsx | 28 +- .../admin/manage/cutting/cutting-sub-row.tsx | 457 +++++++++--------- .../js/pages/admin/manage/cutting/index.tsx | 54 ++- .../js/pages/admin/manage/cutting/show.tsx | 9 +- routes/web.php | 1 + 9 files changed, 394 insertions(+), 288 deletions(-) diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index aa75aa9..bba7b93 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -9,6 +9,7 @@ use App\Models\Cutting; use App\Services\Admin\Manage\CuttingService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -93,4 +94,12 @@ public function destroy(Cutting $cutting): RedirectResponse 'admin.manage.cuttings.index' ); } + + public function materials(Cutting $cutting): JsonResponse + { + return response()->json([ + 'materials' => $this->service->getMaterials($cutting), + 'combinations' => $this->service->getCombinations($cutting), + ]); + } } diff --git a/app/Services/Admin/Manage/CuttingService.php b/app/Services/Admin/Manage/CuttingService.php index a94b299..7f2d047 100644 --- a/app/Services/Admin/Manage/CuttingService.php +++ b/app/Services/Admin/Manage/CuttingService.php @@ -24,17 +24,21 @@ public function __construct( public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { + $materialsCountQuery = '(SELECT COUNT(*) FROM cutting_materials WHERE cutting_materials.cutting_id = cuttings.id AND cutting_materials.deleted_at IS NULL)'; + $totalUsageQuery = '(SELECT IFNULL(SUM(material_usage), 0) FROM cutting_materials WHERE cutting_materials.cutting_id = cuttings.id AND cutting_materials.deleted_at IS NULL)'; + $productNameQuery = '(SELECT product_name FROM cutting_results WHERE cutting_results.cutting_id = cuttings.id AND cutting_results.deleted_at IS NULL LIMIT 1)'; + $cuttingResultQuery = '(SELECT cutting_result FROM cutting_results WHERE cutting_results.cutting_id = cuttings.id AND cutting_results.deleted_at IS NULL LIMIT 1)'; + $paginator = Cutting::query() ->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at']) ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', - 'cuttingResults:id,cutting_id,product_name,cutting_result', - 'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id', - 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant', - 'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit', - 'cuttingMaterialCombinations:id,cutting_id,material_result', ]) + ->selectRaw("{$materialsCountQuery} as materials_count") + ->selectRaw("{$totalUsageQuery} as total_usage") + ->selectRaw("{$productNameQuery} as product_name") + ->selectRaw("{$cuttingResultQuery} as cutting_result") ->when($search, function ($q) use ($search) { $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%")) ->orWhere('description', 'like', "%{$search}%"); @@ -52,23 +56,42 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = $cutting->photo_conversion_url = $cuttingMedia ? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb')) : null; - - $cutting->cuttingMaterials->each(function (CuttingMaterial $material) { - $media = $material->rawMaterialPrice?->getFirstMedia('images'); - if ($material->rawMaterialPrice) { - $material->rawMaterialPrice->photo_url = $media - ? $this->s3Service->getTemporaryUrl($media->getPath()) - : null; - $material->rawMaterialPrice->photo_conversion_url = $media - ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) - : null; - } - }); }); return $paginator; } + public function getMaterials(Cutting $cutting): \Illuminate\Support\Collection + { + return $cutting->cuttingMaterials() + ->select(['id', 'cutting_id', 'raw_material_price_id', 'material_usage', 'material_result', 'combination_id']) + ->with([ + 'rawMaterialPrice:id,raw_material_id,variant', + 'rawMaterialPrice.rawMaterial:id,name,unit', + ]) + ->get() + ->each(function (CuttingMaterial $material) { + if (! $material->rawMaterialPrice) { + return; + } + + $media = $material->rawMaterialPrice->getFirstMedia('images'); + $material->rawMaterialPrice->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->getPath()) + : null; + $material->rawMaterialPrice->photo_conversion_url = $media + ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) + : null; + }); + } + + public function getCombinations(Cutting $cutting): \Illuminate\Support\Collection + { + return $cutting->cuttingMaterialCombinations() + ->select(['id', 'cutting_id', 'material_result']) + ->get(); + } + public function getProductNames(): Collection { return CuttingResult::query() diff --git a/docs/2026-08-14-lazy-loading-card-table.md b/docs/2026-08-14-lazy-loading-card-table.md index 6198f10..47a02fe 100644 --- a/docs/2026-08-14-lazy-loading-card-table.md +++ b/docs/2026-08-14-lazy-loading-card-table.md @@ -4,14 +4,14 @@ ## Date 2026-08-14 ## Goal -Optimize page load performance for raw materials, products, and purchases list pages by implementing lazy loading for child/variant data in card-based layouts. +Optimize page load performance for raw materials, products, purchases, and cutting 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), causing slow initial page loads. +Pages with 100+ records loaded all child data eagerly (variants, items, materials), causing slow initial page loads. ## Solution Pattern 1. **Backend**: Remove eager-loaded relationships from paginated query -2. **Backend**: Add SQL subqueries for summary data (`variants_count`, `total_qty`, `material_name`, `unit`) +2. **Backend**: Add SQL subqueries for summary data (`variants_count`, `total_qty`, `material_name`, `unit`, `materials_count`, `total_usage`, `product_name`, `cutting_result`) 3. **Backend**: Add new controller method returning JSON for lazy load 4. **Frontend**: Use `useCardTableExpand(false)` for default collapsed state 5. **Frontend**: Fetch child data via `fetch()` on expand, store in `Record` state @@ -19,6 +19,16 @@ ## Solution Pattern ## Files Modified +### Cutting (this session) +- `app/Services/Admin/Manage/CuttingService.php` - `paginated()` no longer eager loads `cuttingResults`, `cuttingMaterials`, `cuttingMaterialCombinations`; added `getMaterials()` and `getCombinations()` methods +- `app/Http/Controllers/Admin/Manage/CuttingController.php` - added `materials()` method +- `routes/web.php` - added `GET cuttings/{cutting}/materials` route +- `resources/js/pages/admin/manage/cutting/columns.tsx` - `Cutting` type: `materials_count`, `total_usage`, `product_name`, `cutting_result` +- `resources/js/pages/admin/manage/cutting/cutting-card.tsx` - uses summary data from props +- `resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx` - accepts `materials`, `combinations`, `isLoading` props +- `resources/js/pages/admin/manage/cutting/index.tsx` - lazy loading with `fetchMaterials()` +- `resources/js/pages/admin/manage/cutting/show.tsx` - passes full data to sub-row + ### Purchases (this session) - `app/Services/Admin/Manage/PurchaseService.php` - `paginated()` no longer eager loads `purchaseItems`; added `getItems()` method - `app/Http/Controllers/Admin/Manage/PurchaseController.php` - added `items()` method @@ -32,18 +42,20 @@ ### Raw Materials & Products (previous sessions) - Same pattern applied to `RawMaterialService`, `RawMaterialController`, `ProductService`, `ProductController` - Frontend pages updated with lazy loading state -## Route Added +## Routes Added ```php 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'); ``` ## Key Implementation Details - SQL subqueries avoid N+1 queries while keeping summary data in the paginated response -- `getItems()` method handles media URL resolution for item photos +- `getItems()` / `getMaterials()` / `getCombinations()` methods handle media URL resolution for photos - Frontend caches loaded items to avoid re-fetching on repeated expand/collapse - Loading state shown while items are being fetched +- Show pages (e.g., `show.tsx`) pass full data directly to sub-row components ## Verification - PHP syntax check: OK -- TypeScript: Only pre-existing errors in `use-purchase-draft.ts` (unrelated) +- TypeScript: Only pre-existing errors in `use-*-draft.ts` files (unrelated) - Route cache cleared diff --git a/resources/js/pages/admin/manage/cutting/columns.tsx b/resources/js/pages/admin/manage/cutting/columns.tsx index 97e043e..24c9a28 100644 --- a/resources/js/pages/admin/manage/cutting/columns.tsx +++ b/resources/js/pages/admin/manage/cutting/columns.tsx @@ -33,35 +33,38 @@ export type Cutting = { photo_conversion_url: string | null; created_at: string; formatted_created_at: string; + materials_count: number; + total_usage: number; + product_name: string | null; + cutting_result: number | null; created_by: { id: number; user_profile: { full_name: string; }; }; - cutting_results: CuttingResult[]; - cutting_materials: { + cutting_results?: CuttingResult[]; + cutting_materials?: CuttingMaterialDetail[]; + cutting_material_combinations?: CuttingCombination[]; +}; + +export type CuttingMaterialDetail = { + id: number; + raw_material_price_id: number; + material_usage: number; + material_result: number | null; + combination_id: number | null; + raw_material_price: { id: number; - raw_material_price_id: number; - material_usage: number; - material_result: number | null; - combination_id: number | null; - raw_material_price: { + variant: string; + photo_url: string | null; + photo_conversion_url: string | null; + raw_material: { id: number; - variant: string; - photo_url: string | null; - photo_conversion_url: string | null; - raw_material: { - id: number; - name: string; - unit: string; - }; + name: string; + unit: string; }; - }[]; - cutting_material_combinations: { - id: number; - material_result: number | null; - }[]; + }; }; export type CuttingForEdit = { diff --git a/resources/js/pages/admin/manage/cutting/cutting-card.tsx b/resources/js/pages/admin/manage/cutting/cutting-card.tsx index 8f3af82..92392a7 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-card.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-card.tsx @@ -105,22 +105,10 @@ export function CuttingCardRow({ const { can } = useCan(); const [shareOpen, setShareOpen] = useState(false); const [copied, setCopied] = useState(false); - const items = cutting.cutting_materials ?? []; - const result = cutting.cutting_results?.[0]; - const singleCount = items.filter( - (item) => item.combination_id === null, - ).length; - const comboCount = new Set( - items - .filter((item) => item.combination_id !== null) - .map((item) => item.combination_id), - ).size; - const materialCount = singleCount + comboCount; - const productName = result?.product_name ?? '-'; - const totalUsage = items.reduce( - (sum, item) => sum + Number(item.material_usage), - 0, - ); + const materialsCount = cutting.materials_count ?? 0; + const totalUsage = cutting.total_usage ?? 0; + const productName = cutting.product_name ?? '-'; + const cuttingResult = cutting.cutting_result ?? null; const shareText = generateWhatsappText(cutting); @@ -161,9 +149,9 @@ export function CuttingCardRow({
- {materialCount > 0 && ( + {materialsCount > 0 && ( - {materialCount} bahan baku + {materialsCount} bahan baku )} {cutting.description && ( @@ -193,12 +181,12 @@ export function CuttingCardRow({ {formatNumber(totalUsage)} - {result?.cutting_result && ( + {cuttingResult && ( Hasil:{' '} - {formatNumber(result.cutting_result)} + {formatNumber(cuttingResult)} )} {cutting.formatted_cost_per_unit && ( diff --git a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx index fadf1d4..7e3e231 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx @@ -9,11 +9,21 @@ import { } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; import { Fragment } from 'react'; -import type { Cutting } from './columns'; +import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns'; -export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { - const items = cutting.cutting_materials ?? []; - const combinations = cutting.cutting_material_combinations ?? []; +export function CuttingItemSubRow({ + cutting, + materials: loadedMaterials, + combinations: loadedCombinations, + isLoading, +}: { + cutting: Cutting; + materials: CuttingMaterialDetail[]; + combinations: CuttingCombination[]; + isLoading: boolean; +}) { + const items = loadedMaterials ?? []; + const combinations = loadedCombinations ?? []; const singles = items.filter((item) => item.combination_id === null); const comboItems = items.filter((item) => item.combination_id !== null); @@ -67,220 +77,16 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { - {hasSingle && ( - <> - - - Single - - - {Object.entries(singleByRawMaterial).map( - ([rawMaterialName, groupItems]) => { - const totalPemakaian = groupItems.reduce( - (sum, item) => - sum + Number(item.material_usage), - 0, - ); - const totalHasil = groupItems.reduce( - (sum, item) => - sum + - (item.material_result !== null - ? Number(item.material_result) - : 0), - 0, - ); - - return ( - - - - {rawMaterialName} - - - {formatNumber( - totalPemakaian, - )} - - - {formatNumber( - totalHasil, - )} - - - {groupItems.map((item) => { - counter++; - - return ( - - - {counter} - - - {item.raw_material_price - ?.photo_url ? ( - - ) : ( -
- N/A -
- )} -
- - {item.raw_material_price - ?.variant ?? '-'} - - - {formatNumber( - item.material_usage, - )} - - - {item.material_result !== - null - ? formatNumber( - item.material_result, - ) - : '-'} - -
- ); - })} -
- ); - }, - )} - - )} - - {hasCombo && ( - <> - - - Kombinasi - - - {Object.entries(comboGroups).map( - ([comboId, materials]) => { - counter++; - const combo = combinations.find( - (c) => c.id === Number(comboId), - ); - - return ( - - - - {counter} - - - Kombinasi{' '} - {counter} - {combo?.material_result !== - null && - combo?.material_result !== - undefined && ( - - (Hasil:{' '} - {formatNumber( - combo.material_result, - )} - ) - - )} - - - {materials.map((item) => ( - - - - {item - .raw_material_price - ?.photo_url ? ( - - ) : ( -
- N/A -
- )} -
- - {item - .raw_material_price - ?.raw_material - ?.name ?? '-'}{' '} - -{' '} - {item - .raw_material_price - ?.variant ?? '-'} - - - {formatNumber( - item.material_usage, - )} - - - - - -
- ))} -
- ); - }, - )} - - )} - - {items.length === 0 && ( + {isLoading ? ( + + + Memuat bahan baku... + + + ) : items.length === 0 ? ( + ) : ( + <> + {hasSingle && ( + <> + + + Single + + + {Object.entries(singleByRawMaterial).map( + ([rawMaterialName, groupItems]) => { + const totalPemakaian = groupItems.reduce( + (sum, item) => + sum + Number(item.material_usage), + 0, + ); + const totalHasil = groupItems.reduce( + (sum, item) => + sum + + (item.material_result !== null + ? Number(item.material_result) + : 0), + 0, + ); + + return ( + + + + {rawMaterialName} + + + {formatNumber( + totalPemakaian, + )} + + + {formatNumber( + totalHasil, + )} + + + {groupItems.map((item) => { + counter++; + + return ( + + + {counter} + + + {item.raw_material_price + ?.photo_url ? ( + + ) : ( +
+ N/A +
+ )} +
+ + {item.raw_material_price + ?.variant ?? '-'} + + + {formatNumber( + item.material_usage, + )} + + + {item.material_result !== + null + ? formatNumber( + item.material_result, + ) + : '-'} + +
+ ); + })} +
+ ); + }, + )} + + )} + + {hasCombo && ( + <> + + + Kombinasi + + + {Object.entries(comboGroups).map( + ([comboId, materials]) => { + counter++; + const combo = combinations.find( + (c) => c.id === Number(comboId), + ); + + return ( + + + + {counter} + + + Kombinasi{' '} + {counter} + {combo?.material_result !== + null && + combo?.material_result !== + undefined && ( + + (Hasil:{' '} + {formatNumber( + combo.material_result, + )} + ) + + )} + + + {materials.map((item) => ( + + + + {item + .raw_material_price + ?.photo_url ? ( + + ) : ( +
+ N/A +
+ )} +
+ + {item + .raw_material_price + ?.raw_material + ?.name ?? '-'}{' '} + -{' '} + {item + .raw_material_price + ?.variant ?? '-'} + + + {formatNumber( + item.material_usage, + )} + + + - + +
+ ))} +
+ ); + }, + )} + + )} + )}
diff --git a/resources/js/pages/admin/manage/cutting/index.tsx b/resources/js/pages/admin/manage/cutting/index.tsx index 7192645..17978a2 100644 --- a/resources/js/pages/admin/manage/cutting/index.tsx +++ b/resources/js/pages/admin/manage/cutting/index.tsx @@ -1,6 +1,6 @@ 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 { DeleteConfirmDialog } from '@/components/dialogs'; import { FilterPopover } from '@/components/data-display'; @@ -29,8 +29,9 @@ import { create as cuttingCreate, index as cuttingIndex, edit as cuttingEdit, + materials as cuttingMaterials, } from '@/routes/admin/manage/cuttings'; -import type { Cutting } from './columns'; +import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns'; import { CuttingCardRow } from './cutting-card'; import { CuttingItemSubRow } from './cutting-sub-row'; @@ -59,7 +60,10 @@ export default function CuttingIndex({ }: Props) { const { can } = useCan(); const [deleting, setDeleting] = useState(null); - const expand = useCardTableExpand(true); + const [loadedMaterials, setLoadedMaterials] = useState>({}); + const [loadedCombinations, setLoadedCombinations] = useState>({}); + const [loadingMaterials, setLoadingMaterials] = useState>({}); + const expand = useCardTableExpand(false); const pagination = { current_page: cuttings.current_page, @@ -104,6 +108,34 @@ export default function CuttingIndex({ [filterOptions.productNames, filters.product_name], ); + const fetchMaterials = useCallback((cutting: Cutting) => { + if (loadedMaterials[cutting.id] || loadingMaterials[cutting.id]) { + return; + } + + setLoadingMaterials((prev) => ({ ...prev, [cutting.id]: true })); + + fetch(cuttingMaterials.url(cutting.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedMaterials((prev) => ({ + ...prev, + [cutting.id]: data.materials ?? [], + })); + setLoadedCombinations((prev) => ({ + ...prev, + [cutting.id]: data.combinations ?? [], + })); + }) + .catch(() => { + setLoadedMaterials((prev) => ({ ...prev, [cutting.id]: [] })); + setLoadedCombinations((prev) => ({ ...prev, [cutting.id]: [] })); + }) + .finally(() => { + setLoadingMaterials((prev) => ({ ...prev, [cutting.id]: false })); + }); + }, [loadedMaterials, loadingMaterials]); + const filterToolbar = ( c.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const c = cuttings.data.find((r) => r.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + if (c && !isCurrentlyExpanded) { + fetchMaterials(c); + } + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -229,7 +268,12 @@ export default function CuttingIndex({ /> )} renderSubContent={(cutting) => ( - + )} /> diff --git a/resources/js/pages/admin/manage/cutting/show.tsx b/resources/js/pages/admin/manage/cutting/show.tsx index 7a1b920..1a3df9d 100644 --- a/resources/js/pages/admin/manage/cutting/show.tsx +++ b/resources/js/pages/admin/manage/cutting/show.tsx @@ -29,7 +29,7 @@ const STATUS_BADGE_CLASSES: Record = { export default function CuttingShow({ cutting }: Props) { const items = cutting.cutting_materials ?? []; const result = cutting.cutting_results?.[0]; - const totalUsage = items.reduce( + const totalUsage = cutting.total_usage ?? items.reduce( (sum, item) => sum + Number(item.material_usage), 0, ); @@ -161,7 +161,12 @@ export default function CuttingShow({ cutting }: Props) {
Geser kesamping untuk melihat lebih banyak - + )} diff --git a/routes/web.php b/routes/web.php index e31ae61..6a13813 100644 --- a/routes/web.php +++ b/routes/web.php @@ -69,6 +69,7 @@ Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view'); Route::resource('cuttings', CuttingController::class)->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete'); + Route::get('cuttings/{cutting}/materials', [CuttingController::class, 'materials'])->name('cuttings.materials')->middleware('permission:cuttings.view'); Route::get('cuttings/{cutting}/share', [CuttingController::class, 'share'])->name('cuttings.share'); Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete');