feat: implement lazy loading for restock items, optimize data fetching in Restock service and controller
This commit is contained in:
parent
0344648b74
commit
c2354440e3
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Restock;
|
use App\Models\Restock;
|
||||||
use App\Services\Admin\Manage\RestockService;
|
use App\Services\Admin\Manage\RestockService;
|
||||||
use App\Services\Admin\Master\Product\ProductVariantService;
|
use App\Services\Admin\Master\Product\ProductVariantService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -72,4 +73,11 @@ public function destroy(Restock $restock): RedirectResponse
|
|||||||
'admin.manage.restocks.index'
|
'admin.manage.restocks.index'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function items(Restock $restock): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'items' => $this->service->getItems($restock),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,17 +25,19 @@ public function __construct(
|
|||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
|
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()
|
$paginator = Restock::query()
|
||||||
->select(['id', 'created_by_id', 'total', 'notes', 'stock_type', 'created_at'])
|
->select(['id', 'created_by_id', 'total', 'notes', 'stock_type', 'created_at'])
|
||||||
->with([
|
->with([
|
||||||
'createdBy:id',
|
'createdBy:id',
|
||||||
'createdBy.userProfile:id,user_id,full_name',
|
'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) {
|
->when($search, function ($q) use ($search) {
|
||||||
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||||
->orWhere('notes', 'like', "%{$search}%");
|
->orWhere('notes', 'like', "%{$search}%");
|
||||||
@ -43,8 +45,17 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
->orderBy($sort, $direction)
|
->orderBy($sort, $direction)
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
$paginator->getCollection()->each(function (Restock $restock) {
|
return $paginator;
|
||||||
$restock->restockItems->each(function (RestockItem $item) {
|
}
|
||||||
|
|
||||||
|
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) {
|
if (! $item->productVariant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -57,9 +68,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 store(array $data): Restock
|
public function store(array $data): Restock
|
||||||
|
|||||||
@ -4,7 +4,7 @@ ## Date
|
|||||||
2026-08-14
|
2026-08-14
|
||||||
|
|
||||||
## Goal
|
## 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
|
## Problem
|
||||||
Pages with 100+ records loaded all child data eagerly (variants, items, materials), causing slow initial page loads.
|
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
|
## 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)
|
### Transaction (this session)
|
||||||
- `app/Services/Admin/Manage/TransactionService.php` - `paginated()` no longer eager loads `orderItems`; added `getItems()` method
|
- `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
|
- `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('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('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('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
|
## Key Implementation Details
|
||||||
|
|||||||
@ -25,13 +25,16 @@ export type Restock = {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
stock_type: RestockStockType;
|
stock_type: RestockStockType;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
items_count: number;
|
||||||
|
total_qty: number;
|
||||||
|
product_names: string | null;
|
||||||
created_by: {
|
created_by: {
|
||||||
id: number;
|
id: number;
|
||||||
user_profile: {
|
user_profile: {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
restock_items: RestockItem[];
|
restock_items?: RestockItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestockForEdit = {
|
export type RestockForEdit = {
|
||||||
|
|||||||
@ -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 { useState } from 'react';
|
import { useCallback, 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 { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||||
@ -13,8 +13,9 @@ import {
|
|||||||
create as restockCreate,
|
create as restockCreate,
|
||||||
index as restockIndex,
|
index as restockIndex,
|
||||||
edit as restockEdit,
|
edit as restockEdit,
|
||||||
|
items as restockItems,
|
||||||
} from '@/routes/admin/manage/restocks';
|
} from '@/routes/admin/manage/restocks';
|
||||||
import type { Restock } from './columns';
|
import type { Restock, RestockItem } from './columns';
|
||||||
import { RestockCardRow } from './restock-card';
|
import { RestockCardRow } from './restock-card';
|
||||||
import { RestockItemSubRow } from './restock-sub-row';
|
import { RestockItemSubRow } from './restock-sub-row';
|
||||||
|
|
||||||
@ -31,7 +32,9 @@ type Props = {
|
|||||||
export default function RestockIndex({ restocks }: Props) {
|
export default function RestockIndex({ restocks }: Props) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const [deleting, setDeleting] = useState<Restock | null>(null);
|
const [deleting, setDeleting] = useState<Restock | null>(null);
|
||||||
const expand = useCardTableExpand(true);
|
const [loadedItems, setLoadedItems] = useState<Record<number, RestockItem[]>>({});
|
||||||
|
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
|
||||||
|
const expand = useCardTableExpand(false);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
current_page: restocks.current_page,
|
current_page: restocks.current_page,
|
||||||
@ -50,6 +53,29 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
pagination,
|
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() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
return;
|
||||||
@ -83,7 +109,14 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
data={restocks.data}
|
data={restocks.data}
|
||||||
getItemKey={(r) => r.id}
|
getItemKey={(r) => r.id}
|
||||||
expandedKeys={expand.expandedKeys}
|
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}
|
searchValue={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|
||||||
@ -113,7 +146,11 @@ export default function RestockIndex({ restocks }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderSubContent={(restock) => (
|
renderSubContent={(restock) => (
|
||||||
<RestockItemSubRow restock={restock} />
|
<RestockItemSubRow
|
||||||
|
restock={restock}
|
||||||
|
items={loadedItems[restock.id] ?? []}
|
||||||
|
isLoading={loadingItems[restock.id] ?? false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -40,19 +40,10 @@ export function RestockCardRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: RestockCardRowParams) {
|
}: RestockCardRowParams) {
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const items = restock.restock_items ?? [];
|
const variantCount = restock.items_count ?? 0;
|
||||||
const variantCount = items.length;
|
const totalQty = restock.total_qty ?? 0;
|
||||||
const productNames = [
|
const productNamesStr = restock.product_names ?? '';
|
||||||
...new Set(
|
const productNames = productNamesStr ? productNamesStr.split(', ') : [];
|
||||||
items
|
|
||||||
.map((item) => item.product_variant?.product?.name)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const totalQty = items.reduce(
|
|
||||||
(sum, item) => sum + Number(item.quantity),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const stockTypeConfig =
|
const stockTypeConfig =
|
||||||
STOCK_TYPE_CONFIG[restock.stock_type] ?? STOCK_TYPE_CONFIG.good;
|
STOCK_TYPE_CONFIG[restock.stock_type] ?? STOCK_TYPE_CONFIG.good;
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,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 { Restock } from './columns';
|
import type { Restock, RestockItem } from './columns';
|
||||||
|
|
||||||
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
export function RestockItemSubRow({
|
||||||
const items = restock.restock_items ?? [];
|
restock,
|
||||||
|
items: loadedItems,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
restock: Restock;
|
||||||
|
items: RestockItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const items = loadedItems ?? [];
|
||||||
|
|
||||||
const groupedByProduct = items.reduce(
|
const groupedByProduct = items.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@ -50,7 +58,16 @@ acc[name] = [];
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
Memuat item...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
|
|||||||
@ -77,6 +77,7 @@
|
|||||||
Route::get('transactions/{transaction}/items', [TransactionController::class, 'items'])->name('transactions.items')->middleware('permission:orders.view');
|
Route::get('transactions/{transaction}/items', [TransactionController::class, 'items'])->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::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 () {
|
Route::prefix('finance')->name('admin.finance.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user