diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php index 19114a0..a77a2ee 100644 --- a/app/Http/Controllers/Admin/Manage/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -9,6 +9,7 @@ use App\Services\Admin\Manage\PurchaseService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; use App\Services\Admin\Master\SupplierService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -79,4 +80,11 @@ public function destroy(Purchase $purchase): RedirectResponse 'admin.manage.purchases.index' ); } + + public function items(Purchase $purchase): JsonResponse + { + return response()->json([ + 'items' => $this->service->getItems($purchase), + ]); + } } diff --git a/app/Services/Admin/Manage/PurchaseService.php b/app/Services/Admin/Manage/PurchaseService.php index 183e7fa..0380aa1 100644 --- a/app/Services/Admin/Manage/PurchaseService.php +++ b/app/Services/Admin/Manage/PurchaseService.php @@ -24,18 +24,22 @@ public function __construct( public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { + $itemCountQuery = '(SELECT COUNT(*) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)'; + $totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)'; + $materialNameQuery = '(SELECT raw_materials.name FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)'; + $unitQuery = '(SELECT raw_materials.unit FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)'; + $paginator = Purchase::query() ->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at']) ->with([ 'supplier:id,name', 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', - 'purchaseItems' => fn ($q) => $q - ->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal']) - ->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'), - 'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock', - 'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit', ]) + ->selectRaw("{$itemCountQuery} as variants_count") + ->selectRaw("{$totalQtyQuery} as total_qty") + ->selectRaw("{$materialNameQuery} as material_name") + ->selectRaw("{$unitQuery} as unit") ->when($search, function ($q) use ($search) { $q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) ->orWhere('notes', 'like', "%{$search}%"); @@ -52,8 +56,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = $purchase->photo_conversion_url = $purchaseMedia ? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb')) : null; + }); - $purchase->purchaseItems->each(function (PurchaseItem $item) { + return $paginator; + } + + public function getItems(Purchase $purchase): \Illuminate\Support\Collection + { + return $purchase->purchaseItems() + ->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal']) + ->with(['rawMaterialPrice:id,raw_material_id,variant,price,stock', 'rawMaterialPrice.rawMaterial:id,name,unit']) + ->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)') + ->get() + ->each(function (PurchaseItem $item) { if (! $item->rawMaterialPrice) { return; } @@ -66,9 +81,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : null; }); - }); - - return $paginator; } public function getForCreate(): array diff --git a/docs/2026-08-14-lazy-loading-card-table.md b/docs/2026-08-14-lazy-loading-card-table.md new file mode 100644 index 0000000..6198f10 --- /dev/null +++ b/docs/2026-08-14-lazy-loading-card-table.md @@ -0,0 +1,49 @@ +# Lazy Loading Card Table + +## 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. + +## Problem +Pages with 100+ records loaded all child data eagerly (variants, items), 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`) +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 +6. **Frontend**: Pass `items` + `isLoading` props to sub-row components + +## Files Modified + +### 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 +- `routes/web.php` - added `GET purchases/{purchase}/items` route +- `resources/js/pages/admin/manage/purchase/columns.tsx` - `Purchase` type: `variants_count`, `total_qty`, `material_name`, `unit` +- `resources/js/pages/admin/manage/purchase/purchase-card.tsx` - uses summary data from props +- `resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx` - accepts `items` & `isLoading` props +- `resources/js/pages/admin/manage/purchase/index.tsx` - lazy loading with `fetchItems()` + +### Raw Materials & Products (previous sessions) +- Same pattern applied to `RawMaterialService`, `RawMaterialController`, `ProductService`, `ProductController` +- Frontend pages updated with lazy loading state + +## Route Added +```php +Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.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 +- Frontend caches loaded items to avoid re-fetching on repeated expand/collapse +- Loading state shown while items are being fetched + +## Verification +- PHP syntax check: OK +- TypeScript: Only pre-existing errors in `use-purchase-draft.ts` (unrelated) +- Route cache cleared diff --git a/resources/js/pages/admin/manage/purchase/columns.tsx b/resources/js/pages/admin/manage/purchase/columns.tsx index bbf5af5..1678123 100644 --- a/resources/js/pages/admin/manage/purchase/columns.tsx +++ b/resources/js/pages/admin/manage/purchase/columns.tsx @@ -19,6 +19,10 @@ export type Purchase = { photo_url: string | null; photo_conversion_url: string | null; created_at: string; + variants_count: number; + total_qty: number; + material_name: string | null; + unit: string | null; supplier: { id: number; name: string; @@ -29,26 +33,28 @@ export type Purchase = { full_name: string; }; }; - purchase_items: { + purchase_items?: PurchaseItemDetail[]; +}; + +export type PurchaseItemDetail = { + id: number; + raw_material_price_id: number; + quantity: number; + unit_price: number; + subtotal: number; + raw_material_price: { id: number; - raw_material_price_id: number; - quantity: number; - unit_price: number; - subtotal: number; - raw_material_price: { + variant: string; + price: number; + stock: number; + photo_url: string | null; + photo_conversion_url: string | null; + raw_material: { id: number; - variant: string; - price: number; - stock: number; - photo_url: string | null; - photo_conversion_url: string | null; - raw_material: { - id: number; - name: string; - unit: string; - }; + name: string; + unit: string; }; - }[]; + }; }; export type PurchaseForEdit = { diff --git a/resources/js/pages/admin/manage/purchase/index.tsx b/resources/js/pages/admin/manage/purchase/index.tsx index d1c5776..db2528f 100644 --- a/resources/js/pages/admin/manage/purchase/index.tsx +++ b/resources/js/pages/admin/manage/purchase/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'; @@ -22,8 +22,9 @@ import { create as purchaseCreate, edit as purchaseEdit, index as purchaseIndex, + items as purchaseItems, } from '@/routes/admin/manage/purchases'; -import type { Purchase } from './columns'; +import type { Purchase, PurchaseItemDetail } from './columns'; import { PurchaseCardRow } from './purchase-card'; import { PurchaseItemSubRow } from './purchase-sub-row'; @@ -51,7 +52,9 @@ export default function PurchaseIndex({ }: 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: purchases.current_page, @@ -84,6 +87,29 @@ export default function PurchaseIndex({ [suppliers, filters.supplier_id], ); + const fetchItems = useCallback((purchase: Purchase) => { + if (loadedItems[purchase.id] || loadingItems[purchase.id]) { + return; + } + + setLoadingItems((prev) => ({ ...prev, [purchase.id]: true })); + + fetch(purchaseItems.url(purchase.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedItems((prev) => ({ + ...prev, + [purchase.id]: data.items ?? [], + })); + }) + .catch(() => { + setLoadedItems((prev) => ({ ...prev, [purchase.id]: [] })); + }) + .finally(() => { + setLoadingItems((prev) => ({ ...prev, [purchase.id]: false })); + }); + }, [loadedItems, loadingItems]); + function handleDelete() { if (!deleting) { return; @@ -161,7 +187,14 @@ export default function PurchaseIndex({ data={purchases.data} getItemKey={(p) => p.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const p = purchases.data.find((r) => r.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + if (p && !isCurrentlyExpanded) { + fetchItems(p); + } + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -192,7 +225,11 @@ export default function PurchaseIndex({ /> )} renderSubContent={(purchase) => ( - + )} /> diff --git a/resources/js/pages/admin/manage/purchase/purchase-card.tsx b/resources/js/pages/admin/manage/purchase/purchase-card.tsx index c518b98..e848b65 100644 --- a/resources/js/pages/admin/manage/purchase/purchase-card.tsx +++ b/resources/js/pages/admin/manage/purchase/purchase-card.tsx @@ -26,15 +26,10 @@ export function PurchaseCardRow({ onDelete, }: PurchaseCardRowParams) { const { can } = useCan(); - const items = purchase.purchase_items ?? []; - const variantCount = items.length; - const rawMaterialName = - items[0]?.raw_material_price?.raw_material?.name ?? '-'; - const unit = items[0]?.raw_material_price?.raw_material?.unit ?? ''; - const totalQty = items.reduce( - (sum, item) => sum + Number(item.quantity), - 0, - ); + const variantCount = purchase.variants_count ?? 0; + const totalQty = purchase.total_qty ?? 0; + const rawMaterialName = purchase.material_name ?? '-'; + const unit = purchase.unit ?? ''; return ( <> diff --git a/resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx b/resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx index 17ed493..f3bf1e8 100644 --- a/resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx +++ b/resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx @@ -9,11 +9,18 @@ import { } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; -import type { Purchase } from './columns'; +import type { Purchase, PurchaseItemDetail } from './columns'; -export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) { - const items = purchase.purchase_items ?? []; - const unit = items[0]?.raw_material_price?.raw_material?.unit ?? ''; +export function PurchaseItemSubRow({ + purchase, + items: loadedItems, + isLoading, +}: { + purchase: Purchase; + items: PurchaseItemDetail[]; + isLoading: boolean; +}) { + const unit = purchase.unit ?? ''; return (
@@ -31,7 +38,16 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) { - {items.length === 0 ? ( + {isLoading ? ( + + + Memuat item... + + + ) : loadedItems.length === 0 ? ( ) : ( - items.map((item, index) => ( + loadedItems.map((item, index) => ( {index + 1} diff --git a/routes/web.php b/routes/web.php index 71ed118..e31ae61 100644 --- a/routes/web.php +++ b/routes/web.php @@ -66,6 +66,7 @@ Route::prefix('manage')->name('admin.manage.')->group(function () { Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchases.view|purchases.create|purchases.update|purchases.delete'); + 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}/share', [CuttingController::class, 'share'])->name('cuttings.share');