feat: implement lazy loading for purchase items and optimize data fetching
This commit is contained in:
parent
ce901c2c8b
commit
113f9a7c4f
@ -9,6 +9,7 @@
|
|||||||
use App\Services\Admin\Manage\PurchaseService;
|
use App\Services\Admin\Manage\PurchaseService;
|
||||||
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
|
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
|
||||||
use App\Services\Admin\Master\SupplierService;
|
use App\Services\Admin\Master\SupplierService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -79,4 +80,11 @@ public function destroy(Purchase $purchase): RedirectResponse
|
|||||||
'admin.manage.purchases.index'
|
'admin.manage.purchases.index'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function items(Purchase $purchase): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'items' => $this->service->getItems($purchase),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
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()
|
$paginator = Purchase::query()
|
||||||
->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at'])
|
->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at'])
|
||||||
->with([
|
->with([
|
||||||
'supplier:id,name',
|
'supplier:id,name',
|
||||||
'createdBy:id',
|
'createdBy:id',
|
||||||
'createdBy.userProfile:id,user_id,full_name',
|
'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) {
|
->when($search, function ($q) use ($search) {
|
||||||
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||||
->orWhere('notes', '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
|
$purchase->photo_conversion_url = $purchaseMedia
|
||||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
|
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
|
||||||
: null;
|
: 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) {
|
if (! $item->rawMaterialPrice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -66,9 +81,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return $paginator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getForCreate(): array
|
public function getForCreate(): array
|
||||||
|
|||||||
49
docs/2026-08-14-lazy-loading-card-table.md
Normal file
49
docs/2026-08-14-lazy-loading-card-table.md
Normal file
@ -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<number, T[]>` 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
|
||||||
@ -19,6 +19,10 @@ export type Purchase = {
|
|||||||
photo_url: string | null;
|
photo_url: string | null;
|
||||||
photo_conversion_url: string | null;
|
photo_conversion_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
variants_count: number;
|
||||||
|
total_qty: number;
|
||||||
|
material_name: string | null;
|
||||||
|
unit: string | null;
|
||||||
supplier: {
|
supplier: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -29,26 +33,28 @@ export type Purchase = {
|
|||||||
full_name: string;
|
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;
|
id: number;
|
||||||
raw_material_price_id: number;
|
variant: string;
|
||||||
quantity: number;
|
price: number;
|
||||||
unit_price: number;
|
stock: number;
|
||||||
subtotal: number;
|
photo_url: string | null;
|
||||||
raw_material_price: {
|
photo_conversion_url: string | null;
|
||||||
|
raw_material: {
|
||||||
id: number;
|
id: number;
|
||||||
variant: string;
|
name: string;
|
||||||
price: number;
|
unit: string;
|
||||||
stock: number;
|
|
||||||
photo_url: string | null;
|
|
||||||
photo_conversion_url: string | null;
|
|
||||||
raw_material: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
unit: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}[];
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PurchaseForEdit = {
|
export type PurchaseForEdit = {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
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 { DeleteConfirmDialog } from '@/components/dialogs';
|
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||||
import { FilterPopover } from '@/components/data-display';
|
import { FilterPopover } from '@/components/data-display';
|
||||||
@ -22,8 +22,9 @@ import {
|
|||||||
create as purchaseCreate,
|
create as purchaseCreate,
|
||||||
edit as purchaseEdit,
|
edit as purchaseEdit,
|
||||||
index as purchaseIndex,
|
index as purchaseIndex,
|
||||||
|
items as purchaseItems,
|
||||||
} from '@/routes/admin/manage/purchases';
|
} from '@/routes/admin/manage/purchases';
|
||||||
import type { Purchase } from './columns';
|
import type { Purchase, PurchaseItemDetail } from './columns';
|
||||||
import { PurchaseCardRow } from './purchase-card';
|
import { PurchaseCardRow } from './purchase-card';
|
||||||
import { PurchaseItemSubRow } from './purchase-sub-row';
|
import { PurchaseItemSubRow } from './purchase-sub-row';
|
||||||
|
|
||||||
@ -51,7 +52,9 @@ export default function PurchaseIndex({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
||||||
const expand = useCardTableExpand(true);
|
const [loadedItems, setLoadedItems] = useState<Record<number, PurchaseItemDetail[]>>({});
|
||||||
|
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
|
||||||
|
const expand = useCardTableExpand(false);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
current_page: purchases.current_page,
|
current_page: purchases.current_page,
|
||||||
@ -84,6 +87,29 @@ export default function PurchaseIndex({
|
|||||||
[suppliers, filters.supplier_id],
|
[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() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
return;
|
||||||
@ -161,7 +187,14 @@ export default function PurchaseIndex({
|
|||||||
data={purchases.data}
|
data={purchases.data}
|
||||||
getItemKey={(p) => p.id}
|
getItemKey={(p) => p.id}
|
||||||
expandedKeys={expand.expandedKeys}
|
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}
|
searchValue={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|
||||||
@ -192,7 +225,11 @@ export default function PurchaseIndex({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderSubContent={(purchase) => (
|
renderSubContent={(purchase) => (
|
||||||
<PurchaseItemSubRow purchase={purchase} />
|
<PurchaseItemSubRow
|
||||||
|
purchase={purchase}
|
||||||
|
items={loadedItems[purchase.id] ?? []}
|
||||||
|
isLoading={loadingItems[purchase.id] ?? false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -26,15 +26,10 @@ export function PurchaseCardRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: PurchaseCardRowParams) {
|
}: PurchaseCardRowParams) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const items = purchase.purchase_items ?? [];
|
const variantCount = purchase.variants_count ?? 0;
|
||||||
const variantCount = items.length;
|
const totalQty = purchase.total_qty ?? 0;
|
||||||
const rawMaterialName =
|
const rawMaterialName = purchase.material_name ?? '-';
|
||||||
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
const unit = purchase.unit ?? '';
|
||||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
|
||||||
const totalQty = items.reduce(
|
|
||||||
(sum, item) => sum + Number(item.quantity),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -9,11 +9,18 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { formatNumber } from '@/lib/format';
|
import { formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import type { Purchase } from './columns';
|
import type { Purchase, PurchaseItemDetail } from './columns';
|
||||||
|
|
||||||
export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
export function PurchaseItemSubRow({
|
||||||
const items = purchase.purchase_items ?? [];
|
purchase,
|
||||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
items: loadedItems,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
purchase: Purchase;
|
||||||
|
items: PurchaseItemDetail[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const unit = purchase.unit ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-x-auto">
|
<div className="space-y-4 overflow-x-auto">
|
||||||
@ -31,7 +38,16 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
Memuat item...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : loadedItems.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
@ -41,7 +57,7 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, index) => (
|
loadedItems.map((item, index) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
|
|||||||
@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
Route::prefix('manage')->name('admin.manage.')->group(function () {
|
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::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::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');
|
Route::get('cuttings/{cutting}/share', [CuttingController::class, 'share'])->name('cuttings.share');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user