diff --git a/app/Http/Controllers/Admin/Manage/TransactionController.php b/app/Http/Controllers/Admin/Manage/TransactionController.php index 0dfd4a0..4b9c69e 100644 --- a/app/Http/Controllers/Admin/Manage/TransactionController.php +++ b/app/Http/Controllers/Admin/Manage/TransactionController.php @@ -13,6 +13,7 @@ use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Master\CustomerService; use App\Services\Admin\Master\Product\ProductVariantService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -106,6 +107,13 @@ public function updateStatus(Order $transaction): RedirectResponse ); } + public function items(Order $transaction): JsonResponse + { + return response()->json([ + 'items' => $this->service->getItems($transaction), + ]); + } + private function getEmployees() { return User::query() diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index 9470066..06d9dbc 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -41,6 +41,10 @@ public function __construct( public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?User $user = null): LengthAwarePaginator { + $itemsCountQuery = '(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)'; + $totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)'; + $productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM order_items oi JOIN product_variants pv ON pv.id = oi.product_variant_id JOIN products p ON p.id = pv.product_id WHERE oi.order_id = orders.id AND oi.deleted_at IS NULL)'; + $paginator = Order::query() ->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at']) ->with([ @@ -49,10 +53,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = 'customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', - 'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price,subtotal', - 'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', - 'orderItems.productVariant.product:id,name', ]) + ->selectRaw("{$itemsCountQuery} as items_count") + ->selectRaw("{$totalQtyQuery} as total_qty") + ->selectRaw("{$productNamesQuery} as product_names") ->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id)) ->when($search, function ($q) use ($search) { $q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) @@ -84,7 +88,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb')) : null; - $order->orderItems->each(function (OrderItem $item) { + $order->profit = $order->total_amount - $order->cogs; + }); + + return $paginator; + } + + public function getItems(Order $order): \Illuminate\Support\Collection + { + return $order->orderItems() + ->select(['id', 'order_id', 'product_variant_id', 'stock_quality', 'quantity', 'unit_price', 'subtotal']) + ->with(['productVariant:id,product_id,name', 'productVariant.product:id,name']) + ->get() + ->each(function (OrderItem $item) { if (! $item->productVariant) { return; } @@ -97,11 +113,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : null; }); - - $order->profit = $order->total_amount - $order->cogs; - }); - - return $paginator; } public function getSummary(array $filters = [], ?User $user = null): array diff --git a/docs/2026-08-14-lazy-loading-card-table.md b/docs/2026-08-14-lazy-loading-card-table.md index 47a02fe..aab260a 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, purchases, and cutting list pages by implementing lazy loading for child/variant data in card-based layouts. +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. ## Problem 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`, `materials_count`, `total_usage`, `product_name`, `cutting_result`) +2. **Backend**: Add SQL subqueries for summary data (`variants_count`, `total_qty`, `material_name`, `unit`, `materials_count`, `total_usage`, `product_name`, `cutting_result`, `items_count`, `product_names`) 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,15 @@ ## Solution Pattern ## Files Modified +### 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 +- `routes/web.php` - added `GET transactions/{transaction}/items` route +- `resources/js/pages/admin/manage/transaction/columns.tsx` - `Transaction` type: `items_count`, `total_qty`, `product_names` +- `resources/js/pages/admin/manage/transaction/transaction-card.tsx` - uses summary data from props +- `resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx` - accepts `items` & `isLoading` props +- `resources/js/pages/admin/manage/transaction/index.tsx` - lazy loading with `fetchItems()`, print fetches items on demand + ### 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 @@ -46,6 +55,7 @@ ## 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'); +Route::get('transactions/{transaction}/items', [TransactionController::class, 'items'])->name('transactions.items')->middleware('permission:orders.view'); ``` ## Key Implementation Details @@ -54,8 +64,9 @@ ## Key Implementation Details - 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 +- Transaction print feature fetches items on demand if not cached ## Verification - PHP syntax check: OK -- TypeScript: Only pre-existing errors in `use-*-draft.ts` files (unrelated) +- TypeScript: Only pre-existing errors in `use-*-draft.ts`, `create.tsx`, `edit.tsx` files (unrelated) - Route cache cleared diff --git a/resources/js/pages/admin/manage/transaction/columns.tsx b/resources/js/pages/admin/manage/transaction/columns.tsx index dfae256..3324319 100644 --- a/resources/js/pages/admin/manage/transaction/columns.tsx +++ b/resources/js/pages/admin/manage/transaction/columns.tsx @@ -53,6 +53,9 @@ export type Transaction = { photo_url: string | null; photo_conversion_url: string | null; created_at: string; + items_count: number; + total_qty: number; + product_names: string | null; created_by: { id: number; user_profile: { @@ -69,7 +72,7 @@ export type Transaction = { full_name: string; }; } | null; - order_items: TransactionItem[]; + order_items?: TransactionItem[]; }; export type TransactionForEdit = { diff --git a/resources/js/pages/admin/manage/transaction/index.tsx b/resources/js/pages/admin/manage/transaction/index.tsx index a27217a..2654bd7 100644 --- a/resources/js/pages/admin/manage/transaction/index.tsx +++ b/resources/js/pages/admin/manage/transaction/index.tsx @@ -1,7 +1,7 @@ import { Head, Link, router, usePage } from '@inertiajs/react'; import { format } from 'date-fns'; import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { CardTable } from '@/components/data-display'; import { DatePicker } from '@/components/inputs'; @@ -41,8 +41,9 @@ import { index as transactionIndex, edit as transactionEdit, updateStatus as transactionUpdateStatus, + items as transactionItems, } from '@/routes/admin/manage/transactions'; -import type { Transaction } from './columns'; +import type { Transaction, TransactionItem } from './columns'; import { TransactionCardRow } from './transaction-card'; import { TransactionItemSubRow } from './transaction-sub-row'; import { TransactionSummaryCard } from './transaction-summary-card'; @@ -100,7 +101,9 @@ export default function TransactionIndex({ const { can } = useCan(); const { name: appName, address: appAddress } = usePage().props as unknown as { name: string; address: string }; const [deleting, setDeleting] = useState(null); - const expand = useCardTableExpand(true); + const [loadedItems, setLoadedItems] = useState>({}); + const [loadingItems, setLoadingItems] = useState>({}); + const expand = useCardTableExpand(false); const printer = useThermalPrinter(); const pagination = { @@ -149,6 +152,29 @@ export default function TransactionIndex({ [filterOptions.employees, filters.created_by_id], ); + const fetchItems = useCallback((transaction: Transaction) => { + if (loadedItems[transaction.id] || loadingItems[transaction.id]) { + return; + } + + setLoadingItems((prev) => ({ ...prev, [transaction.id]: true })); + + fetch(transactionItems.url(transaction.id)) + .then((res) => res.json()) + .then((data) => { + setLoadedItems((prev) => ({ + ...prev, + [transaction.id]: data.items ?? [], + })); + }) + .catch(() => { + setLoadedItems((prev) => ({ ...prev, [transaction.id]: [] })); + }) + .finally(() => { + setLoadingItems((prev) => ({ ...prev, [transaction.id]: false })); + }); + }, [loadedItems, loadingItems]); + function handleDelete() { if (!deleting) { return; @@ -170,44 +196,62 @@ export default function TransactionIndex({ return; } - const rupiah = (n: number) => `Rp ${n.toLocaleString('id-ID')}`; + const printWithItems = (items: TransactionItem[]) => { + const rupiah = (n: number) => `Rp ${n.toLocaleString('id-ID')}`; - const items = (transaction.order_items ?? []).map((item) => ({ - product_name: item.product_variant?.product?.name ?? '-', - variant_name: item.product_variant?.name ?? '-', - quantity: `${Number(item.quantity)}`, - unit_price: rupiah(Number(item.unit_price)), - subtotal: rupiah(Number(item.subtotal)), - })); + const mappedItems = items.map((item) => ({ + product_name: item.product_variant?.product?.name ?? '-', + variant_name: item.product_variant?.name ?? '-', + quantity: `${Number(item.quantity)}`, + unit_price: rupiah(Number(item.unit_price)), + subtotal: rupiah(Number(item.subtotal)), + })); - const cashierName = - transaction.created_by?.user_profile?.full_name ?? '-'; + const cashierName = + transaction.created_by?.user_profile?.full_name ?? '-'; - const data = encodeOrderReceipt( - { - order_number: transaction.order_number, - created_at: format(new Date(transaction.created_at), 'dd/MM/yyyy HH:mm'), - customer_name: transaction.customer?.name ?? null, - cashier_name: cashierName, - items, - subtotal: rupiah(Number(transaction.subtotal)), - discount: rupiah(Number(transaction.discount)), - nego_price: transaction.nego_price != null && Number(transaction.nego_price) > 0 - ? rupiah(Number(transaction.nego_price)) - : null, - total_amount: rupiah(Number(transaction.total_amount)), - notes: transaction.notes ?? null, - }, - { - storeName: appName, - storeAddress: appAddress, - paperWidth, - }, - ); + const data = encodeOrderReceipt( + { + order_number: transaction.order_number, + created_at: format(new Date(transaction.created_at), 'dd/MM/yyyy HH:mm'), + customer_name: transaction.customer?.name ?? null, + cashier_name: cashierName, + items: mappedItems, + subtotal: rupiah(Number(transaction.subtotal)), + discount: rupiah(Number(transaction.discount)), + nego_price: transaction.nego_price != null && Number(transaction.nego_price) > 0 + ? rupiah(Number(transaction.nego_price)) + : null, + total_amount: rupiah(Number(transaction.total_amount)), + notes: transaction.notes ?? null, + }, + { + storeName: appName, + storeAddress: appAddress, + paperWidth, + }, + ); - printer.print(data).catch((err: Error) => { - alert(`Gagal mencetak: ${err.message}`); - }); + printer.print(data).catch((err: Error) => { + alert(`Gagal mencetak: ${err.message}`); + }); + }; + + const cachedItems = loadedItems[transaction.id]; + if (cachedItems) { + printWithItems(cachedItems); + } else { + fetch(transactionItems.url(transaction.id)) + .then((res) => res.json()) + .then((data) => { + const items = data.items ?? []; + setLoadedItems((prev) => ({ ...prev, [transaction.id]: items })); + printWithItems(items); + }) + .catch(() => { + alert('Gagal memuat item transaksi.'); + }); + } } const filterToolbar = ( @@ -495,7 +539,14 @@ export default function TransactionIndex({ data={transactions.data} getItemKey={(t) => t.id} expandedKeys={expand.expandedKeys} - onToggleExpand={expand.toggleExpand} + onToggleExpand={(key) => { + const t = transactions.data.find((r) => r.id === key); + const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key); + if (t && !isCurrentlyExpanded) { + fetchItems(t); + } + expand.toggleExpand(key); + }} searchValue={search} onSearchChange={handleSearchChange} @@ -528,7 +579,11 @@ export default function TransactionIndex({ /> )} renderSubContent={(transaction) => ( - + )} /> diff --git a/resources/js/pages/admin/manage/transaction/transaction-card.tsx b/resources/js/pages/admin/manage/transaction/transaction-card.tsx index db4f5fd..faf9818 100644 --- a/resources/js/pages/admin/manage/transaction/transaction-card.tsx +++ b/resources/js/pages/admin/manage/transaction/transaction-card.tsx @@ -47,19 +47,10 @@ export function TransactionCardRow({ onPrint, }: TransactionCardRowParams) { const { can } = useCan(); - const items = transaction.order_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 = transaction.items_count ?? 0; + const totalQty = transaction.total_qty ?? 0; + const productNamesStr = transaction.product_names ?? ''; + const productNames = productNamesStr ? productNamesStr.split(', ') : []; const statusBadgeClass = STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending; diff --git a/resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx b/resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx index 6451f1e..3a933c4 100644 --- a/resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx +++ b/resources/js/pages/admin/manage/transaction/transaction-sub-row.tsx @@ -9,10 +9,18 @@ import { } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; import { formatCurrency } from '@/lib/utils'; -import type { Transaction } from './columns'; +import type { Transaction, TransactionItem } from './columns'; -export function TransactionItemSubRow({ transaction }: { transaction: Transaction }) { - const items = transaction.order_items ?? []; +export function TransactionItemSubRow({ + transaction, + items: loadedItems, + isLoading, +}: { + transaction: Transaction; + items: TransactionItem[]; + isLoading: boolean; +}) { + const items = loadedItems ?? []; return (
@@ -33,7 +41,16 @@ export function TransactionItemSubRow({ transaction }: { transaction: Transactio - {items.length === 0 ? ( + {isLoading ? ( + + + Memuat item... + + + ) : items.length === 0 ? ( except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete'); Route::patch('transactions/{transaction}/status', [TransactionController::class, 'updateStatus'])->name('transactions.updateStatus')->middleware('permission:orders.update'); + 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'); });