Compare commits

..

No commits in common. "c2354440e3b00c55bd5d2cf2da292abc8743e5c0" and "ce901c2c8bdf6a4bf929246c7b2d4c763eab7235" have entirely different histories.

27 changed files with 427 additions and 829 deletions

View File

@ -9,7 +9,6 @@
use App\Models\Cutting; use App\Models\Cutting;
use App\Services\Admin\Manage\CuttingService; use App\Services\Admin\Manage\CuttingService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -94,12 +93,4 @@ public function destroy(Cutting $cutting): RedirectResponse
'admin.manage.cuttings.index' 'admin.manage.cuttings.index'
); );
} }
public function materials(Cutting $cutting): JsonResponse
{
return response()->json([
'materials' => $this->service->getMaterials($cutting),
'combinations' => $this->service->getCombinations($cutting),
]);
}
} }

View File

@ -9,7 +9,6 @@
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;
@ -80,11 +79,4 @@ 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),
]);
}
} }

View File

@ -8,7 +8,6 @@
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;
@ -73,11 +72,4 @@ 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),
]);
}
} }

View File

@ -13,7 +13,6 @@
use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService; use App\Services\Admin\Master\CustomerService;
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;
@ -107,13 +106,6 @@ public function updateStatus(Order $transaction): RedirectResponse
); );
} }
public function items(Order $transaction): JsonResponse
{
return response()->json([
'items' => $this->service->getItems($transaction),
]);
}
private function getEmployees() private function getEmployees()
{ {
return User::query() return User::query()

View File

@ -24,21 +24,17 @@ 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
{ {
$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() $paginator = Cutting::query()
->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at']) ->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', '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) { ->when($search, function ($q) use ($search) {
$q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%")) $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%"))
->orWhere('description', 'like', "%{$search}%"); ->orWhere('description', 'like', "%{$search}%");
@ -56,42 +52,23 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$cutting->photo_conversion_url = $cuttingMedia $cutting->photo_conversion_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb')) ? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb'))
: null; : 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; 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 public function getProductNames(): Collection
{ {
return CuttingResult::query() return CuttingResult::query()

View File

@ -24,22 +24,18 @@ 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}%");
@ -56,19 +52,8 @@ 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;
});
return $paginator; $purchase->purchaseItems->each(function (PurchaseItem $item) {
}
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;
} }
@ -81,6 +66,9 @@ public function getItems(Purchase $purchase): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) ? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; : null;
}); });
});
return $paginator;
} }
public function getForCreate(): array public function getForCreate(): array

View File

@ -25,19 +25,17 @@ 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}%");
@ -45,17 +43,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
return $paginator; $paginator->getCollection()->each(function (Restock $restock) {
} $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;
} }
@ -68,6 +57,9 @@ public function getItems(Restock $restock): \Illuminate\Support\Collection
? $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

View File

@ -41,10 +41,6 @@ public function __construct(
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?User $user = null): LengthAwarePaginator 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() $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']) ->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([ ->with([
@ -53,10 +49,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'customer:id,name', 'customer:id,name',
'marketing:id', 'marketing:id',
'marketing.userProfile:id,user_id,full_name', '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($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id))
->when($search, function ($q) use ($search) { ->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
@ -88,19 +84,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb')) ? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
: null; : null;
$order->profit = $order->total_amount - $order->cogs; $order->orderItems->each(function (OrderItem $item) {
});
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) { if (! $item->productVariant) {
return; return;
} }
@ -113,6 +97,11 @@ public function getItems(Order $order): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) ? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; : null;
}); });
$order->profit = $order->total_amount - $order->cogs;
});
return $paginator;
} }
public function getSummary(array $filters = [], ?User $user = null): array public function getSummary(array $filters = [], ?User $user = null): array

View File

@ -1,82 +0,0 @@
# Lazy Loading Card Table
## Date
2026-08-14
## Goal
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
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`, `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<number, T[]>` state
6. **Frontend**: Pass `items` + `isLoading` props to sub-row components
## 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)
- `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
- `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
- `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
## 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');
Route::get('restocks/{restock}/items', [RestockController::class, 'items'])->name('restocks.items')->middleware('permission:restocks.view');
```
## Key Implementation Details
- SQL subqueries avoid N+1 queries while keeping summary data in the paginated response
- `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
- Transaction print feature fetches items on demand if not cached
## Verification
- PHP syntax check: OK
- TypeScript: Only pre-existing errors in `use-*-draft.ts`, `create.tsx`, `edit.tsx` files (unrelated)
- Route cache cleared

View File

@ -33,38 +33,35 @@ export type Cutting = {
photo_conversion_url: string | null; photo_conversion_url: string | null;
created_at: string; created_at: string;
formatted_created_at: string; formatted_created_at: string;
materials_count: number;
total_usage: number;
product_name: string | null;
cutting_result: number | null;
created_by: { created_by: {
id: number; id: number;
user_profile: { user_profile: {
full_name: string; full_name: string;
}; };
}; };
cutting_results?: CuttingResult[]; cutting_results: CuttingResult[];
cutting_materials?: CuttingMaterialDetail[]; cutting_materials: {
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; id: number;
variant: string; raw_material_price_id: number;
photo_url: string | null; material_usage: number;
photo_conversion_url: string | null; material_result: number | null;
raw_material: { combination_id: number | null;
raw_material_price: {
id: number; id: number;
name: string; variant: string;
unit: string; photo_url: string | null;
photo_conversion_url: string | null;
raw_material: {
id: number;
name: string;
unit: string;
};
}; };
}; }[];
cutting_material_combinations: {
id: number;
material_result: number | null;
}[];
}; };
export type CuttingForEdit = { export type CuttingForEdit = {

View File

@ -105,10 +105,22 @@ export function CuttingCardRow({
const { can } = useCan(); const { can } = useCan();
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const materialsCount = cutting.materials_count ?? 0; const items = cutting.cutting_materials ?? [];
const totalUsage = cutting.total_usage ?? 0; const result = cutting.cutting_results?.[0];
const productName = cutting.product_name ?? '-'; const singleCount = items.filter(
const cuttingResult = cutting.cutting_result ?? null; (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 shareText = generateWhatsappText(cutting); const shareText = generateWhatsappText(cutting);
@ -149,9 +161,9 @@ export function CuttingCardRow({
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{materialsCount > 0 && ( {materialCount > 0 && (
<span> <span>
{materialsCount} bahan baku {materialCount} bahan baku
</span> </span>
)} )}
{cutting.description && ( {cutting.description && (
@ -181,12 +193,12 @@ export function CuttingCardRow({
</span> </span>
{formatNumber(totalUsage)} {formatNumber(totalUsage)}
</span> </span>
{cuttingResult && ( {result?.cutting_result && (
<span> <span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Hasil:{' '} Hasil:{' '}
</span> </span>
{formatNumber(cuttingResult)} {formatNumber(result.cutting_result)}
</span> </span>
)} )}
{cutting.formatted_cost_per_unit && ( {cutting.formatted_cost_per_unit && (

View File

@ -9,21 +9,11 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { Fragment } from 'react'; import { Fragment } from 'react';
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns'; import type { Cutting } from './columns';
export function CuttingItemSubRow({ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
cutting, const items = cutting.cutting_materials ?? [];
materials: loadedMaterials, const combinations = cutting.cutting_material_combinations ?? [];
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 singles = items.filter((item) => item.combination_id === null);
const comboItems = items.filter((item) => item.combination_id !== null); const comboItems = items.filter((item) => item.combination_id !== null);
@ -77,16 +67,220 @@ export function CuttingItemSubRow({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {hasSingle && (
<TableRow> <>
<TableCell <TableRow>
colSpan={5} <TableCell
className="text-center text-muted-foreground" colSpan={5}
> className="text-center font-bold bg-muted/50"
Memuat bahan baku... >
</TableCell> Single
</TableRow> </TableCell>
) : items.length === 0 ? ( </TableRow>
{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 (
<Fragment key={rawMaterialName}>
<TableRow>
<TableCell
colSpan={3}
className="text-center font-medium text-muted-foreground bg-muted/20"
>
{rawMaterialName}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalPemakaian,
)}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalHasil,
)}
</TableCell>
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell>
{item.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_conversion_url ??
item
.raw_material_price
.photo_url,
]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
{item.material_result !==
null
? formatNumber(
item.material_result,
)
: '-'}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)}
</>
)}
{hasCombo && (
<>
<TableRow>
<TableCell
colSpan={5}
className="text-center font-bold bg-muted/50"
>
Kombinasi
</TableCell>
</TableRow>
{Object.entries(comboGroups).map(
([comboId, materials]) => {
counter++;
const combo = combinations.find(
(c) => c.id === Number(comboId),
);
return (
<Fragment key={comboId}>
<TableRow>
<TableCell className="text-center font-medium">
{counter}
</TableCell>
<TableCell
colSpan={4}
className="font-medium text-muted-foreground bg-muted/20"
>
Kombinasi{' '}
{counter}
{combo?.material_result !==
null &&
combo?.material_result !==
undefined && (
<span className="ml-2">
(Hasil:{' '}
{formatNumber(
combo.material_result,
)}
)
</span>
)}
</TableCell>
</TableRow>
{materials.map((item) => (
<TableRow key={item.id}>
<TableCell></TableCell>
<TableCell>
{item
.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_conversion_url ??
item
.raw_material_price
.photo_url,
]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item
.raw_material_price
?.raw_material
?.name ?? '-'}{' '}
-{' '}
{item
.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
-
</TableCell>
</TableRow>
))}
</Fragment>
);
},
)}
</>
)}
{items.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={5} colSpan={5}
@ -95,221 +289,6 @@ export function CuttingItemSubRow({
Tidak ada bahan baku. Tidak ada bahan baku.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : (
<>
{hasSingle && (
<>
<TableRow>
<TableCell
colSpan={5}
className="text-center font-bold bg-muted/50"
>
Single
</TableCell>
</TableRow>
{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 (
<Fragment key={rawMaterialName}>
<TableRow>
<TableCell
colSpan={3}
className="text-center font-medium text-muted-foreground bg-muted/20"
>
{rawMaterialName}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalPemakaian,
)}
</TableCell>
<TableCell className="text-right font-medium text-muted-foreground bg-muted/20">
{formatNumber(
totalHasil,
)}
</TableCell>
</TableRow>
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell>
{item.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_conversion_url ??
item
.raw_material_price
.photo_url,
]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
{item.material_result !==
null
? formatNumber(
item.material_result,
)
: '-'}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)}
</>
)}
{hasCombo && (
<>
<TableRow>
<TableCell
colSpan={5}
className="text-center font-bold bg-muted/50"
>
Kombinasi
</TableCell>
</TableRow>
{Object.entries(comboGroups).map(
([comboId, materials]) => {
counter++;
const combo = combinations.find(
(c) => c.id === Number(comboId),
);
return (
<Fragment key={comboId}>
<TableRow>
<TableCell className="text-center font-medium">
{counter}
</TableCell>
<TableCell
colSpan={4}
className="font-medium text-muted-foreground bg-muted/20"
>
Kombinasi{' '}
{counter}
{combo?.material_result !==
null &&
combo?.material_result !==
undefined && (
<span className="ml-2">
(Hasil:{' '}
{formatNumber(
combo.material_result,
)}
)
</span>
)}
</TableCell>
</TableRow>
{materials.map((item) => (
<TableRow key={item.id}>
<TableCell></TableCell>
<TableCell>
{item
.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_conversion_url ??
item
.raw_material_price
.photo_url,
]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item
.raw_material_price
?.raw_material
?.name ?? '-'}{' '}
-{' '}
{item
.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
-
</TableCell>
</TableRow>
))}
</Fragment>
);
},
)}
</>
)}
</>
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@ -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 { useCallback, useMemo, useState } from 'react'; import { 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';
@ -29,9 +29,8 @@ import {
create as cuttingCreate, create as cuttingCreate,
index as cuttingIndex, index as cuttingIndex,
edit as cuttingEdit, edit as cuttingEdit,
materials as cuttingMaterials,
} from '@/routes/admin/manage/cuttings'; } from '@/routes/admin/manage/cuttings';
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns'; import type { Cutting } from './columns';
import { CuttingCardRow } from './cutting-card'; import { CuttingCardRow } from './cutting-card';
import { CuttingItemSubRow } from './cutting-sub-row'; import { CuttingItemSubRow } from './cutting-sub-row';
@ -60,10 +59,7 @@ export default function CuttingIndex({
}: Props) { }: Props) {
const { can } = useCan(); const { can } = useCan();
const [deleting, setDeleting] = useState<Cutting | null>(null); const [deleting, setDeleting] = useState<Cutting | null>(null);
const [loadedMaterials, setLoadedMaterials] = useState<Record<number, CuttingMaterialDetail[]>>({}); const expand = useCardTableExpand(true);
const [loadedCombinations, setLoadedCombinations] = useState<Record<number, CuttingCombination[]>>({});
const [loadingMaterials, setLoadingMaterials] = useState<Record<number, boolean>>({});
const expand = useCardTableExpand(false);
const pagination = { const pagination = {
current_page: cuttings.current_page, current_page: cuttings.current_page,
@ -108,34 +104,6 @@ export default function CuttingIndex({
[filterOptions.productNames, filters.product_name], [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 = ( const filterToolbar = (
<FilterPopover <FilterPopover
open={filterOpen} open={filterOpen}
@ -225,14 +193,7 @@ export default function CuttingIndex({
data={cuttings.data} data={cuttings.data}
getItemKey={(c) => c.id} getItemKey={(c) => c.id}
expandedKeys={expand.expandedKeys} expandedKeys={expand.expandedKeys}
onToggleExpand={(key) => { onToggleExpand={expand.toggleExpand}
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} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
@ -268,12 +229,7 @@ export default function CuttingIndex({
/> />
)} )}
renderSubContent={(cutting) => ( renderSubContent={(cutting) => (
<CuttingItemSubRow <CuttingItemSubRow cutting={cutting} />
cutting={cutting}
materials={loadedMaterials[cutting.id] ?? []}
combinations={loadedCombinations[cutting.id] ?? []}
isLoading={loadingMaterials[cutting.id] ?? false}
/>
)} )}
/> />

View File

@ -29,7 +29,7 @@ const STATUS_BADGE_CLASSES: Record<string, string> = {
export default function CuttingShow({ cutting }: Props) { export default function CuttingShow({ cutting }: Props) {
const items = cutting.cutting_materials ?? []; const items = cutting.cutting_materials ?? [];
const result = cutting.cutting_results?.[0]; const result = cutting.cutting_results?.[0];
const totalUsage = cutting.total_usage ?? items.reduce( const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage), (sum, item) => sum + Number(item.material_usage),
0, 0,
); );
@ -161,12 +161,7 @@ export default function CuttingShow({ cutting }: Props) {
</div> </div>
<i className="text-xs md:hidden">Geser kesamping untuk melihat lebih banyak</i> <i className="text-xs md:hidden">Geser kesamping untuk melihat lebih banyak</i>
<CuttingItemSubRow <CuttingItemSubRow cutting={cutting} />
cutting={cutting}
materials={cutting.cutting_materials ?? []}
combinations={cutting.cutting_material_combinations ?? []}
isLoading={false}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -19,10 +19,6 @@ 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;
@ -33,28 +29,26 @@ export type Purchase = {
full_name: string; full_name: string;
}; };
}; };
purchase_items?: PurchaseItemDetail[]; purchase_items: {
};
export type PurchaseItemDetail = {
id: number;
raw_material_price_id: number;
quantity: number;
unit_price: number;
subtotal: number;
raw_material_price: {
id: number; id: number;
variant: string; raw_material_price_id: number;
price: number; quantity: number;
stock: number; unit_price: number;
photo_url: string | null; subtotal: number;
photo_conversion_url: string | null; raw_material_price: {
raw_material: {
id: number; id: number;
name: string; variant: string;
unit: string; price: number;
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 = {

View File

@ -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 { useCallback, useMemo, useState } from 'react'; import { 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,9 +22,8 @@ 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, PurchaseItemDetail } from './columns'; import type { Purchase } from './columns';
import { PurchaseCardRow } from './purchase-card'; import { PurchaseCardRow } from './purchase-card';
import { PurchaseItemSubRow } from './purchase-sub-row'; import { PurchaseItemSubRow } from './purchase-sub-row';
@ -52,9 +51,7 @@ 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 [loadedItems, setLoadedItems] = useState<Record<number, PurchaseItemDetail[]>>({}); const expand = useCardTableExpand(true);
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,
@ -87,29 +84,6 @@ 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;
@ -187,14 +161,7 @@ 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={(key) => { onToggleExpand={expand.toggleExpand}
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}
@ -225,11 +192,7 @@ export default function PurchaseIndex({
/> />
)} )}
renderSubContent={(purchase) => ( renderSubContent={(purchase) => (
<PurchaseItemSubRow <PurchaseItemSubRow purchase={purchase} />
purchase={purchase}
items={loadedItems[purchase.id] ?? []}
isLoading={loadingItems[purchase.id] ?? false}
/>
)} )}
/> />

View File

@ -26,10 +26,15 @@ export function PurchaseCardRow({
onDelete, onDelete,
}: PurchaseCardRowParams) { }: PurchaseCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const variantCount = purchase.variants_count ?? 0; const items = purchase.purchase_items ?? [];
const totalQty = purchase.total_qty ?? 0; const variantCount = items.length;
const rawMaterialName = purchase.material_name ?? '-'; const rawMaterialName =
const unit = purchase.unit ?? ''; 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,
);
return ( return (
<> <>

View File

@ -9,18 +9,11 @@ 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, PurchaseItemDetail } from './columns'; import type { Purchase } from './columns';
export function PurchaseItemSubRow({ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
purchase, const items = purchase.purchase_items ?? [];
items: loadedItems, const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
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">
@ -38,16 +31,7 @@ export function PurchaseItemSubRow({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {items.length === 0 ? (
<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}
@ -57,7 +41,7 @@ export function PurchaseItemSubRow({
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
loadedItems.map((item, index) => ( items.map((item, index) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="text-center"> <TableCell className="text-center">
{index + 1} {index + 1}

View File

@ -25,16 +25,13 @@ 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 = {

View File

@ -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 { useCallback, useState } from 'react'; import { 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,9 +13,8 @@ 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, RestockItem } from './columns'; import type { Restock } from './columns';
import { RestockCardRow } from './restock-card'; import { RestockCardRow } from './restock-card';
import { RestockItemSubRow } from './restock-sub-row'; import { RestockItemSubRow } from './restock-sub-row';
@ -32,9 +31,7 @@ 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 [loadedItems, setLoadedItems] = useState<Record<number, RestockItem[]>>({}); const expand = useCardTableExpand(true);
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,
@ -53,29 +50,6 @@ 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;
@ -109,14 +83,7 @@ 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={(key) => { onToggleExpand={expand.toggleExpand}
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}
@ -146,11 +113,7 @@ export default function RestockIndex({ restocks }: Props) {
/> />
)} )}
renderSubContent={(restock) => ( renderSubContent={(restock) => (
<RestockItemSubRow <RestockItemSubRow restock={restock} />
restock={restock}
items={loadedItems[restock.id] ?? []}
isLoading={loadingItems[restock.id] ?? false}
/>
)} )}
/> />

View File

@ -40,10 +40,19 @@ export function RestockCardRow({
onDelete, onDelete,
}: RestockCardRowParams) { }: RestockCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const variantCount = restock.items_count ?? 0; const items = restock.restock_items ?? [];
const totalQty = restock.total_qty ?? 0; const variantCount = items.length;
const productNamesStr = restock.product_names ?? ''; const productNames = [
const productNames = productNamesStr ? productNamesStr.split(', ') : []; ...new Set(
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;

View File

@ -10,18 +10,10 @@ 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, RestockItem } from './columns'; import type { Restock } from './columns';
export function RestockItemSubRow({ export function RestockItemSubRow({ restock }: { restock: Restock }) {
restock, const items = restock.restock_items ?? [];
items: loadedItems,
isLoading,
}: {
restock: Restock;
items: RestockItem[];
isLoading: boolean;
}) {
const items = loadedItems ?? [];
const groupedByProduct = items.reduce( const groupedByProduct = items.reduce(
(acc, item) => { (acc, item) => {
@ -58,16 +50,7 @@ acc[name] = [];
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {items.length === 0 ? (
<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}

View File

@ -53,9 +53,6 @@ export type Transaction = {
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null; photo_conversion_url: string | null;
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: {
@ -72,7 +69,7 @@ export type Transaction = {
full_name: string; full_name: string;
}; };
} | null; } | null;
order_items?: TransactionItem[]; order_items: TransactionItem[];
}; };
export type TransactionForEdit = { export type TransactionForEdit = {

View File

@ -1,7 +1,7 @@
import { Head, Link, router, usePage } from '@inertiajs/react'; import { Head, Link, router, usePage } from '@inertiajs/react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react'; import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DatePicker } from '@/components/inputs'; import { DatePicker } from '@/components/inputs';
@ -41,9 +41,8 @@ import {
index as transactionIndex, index as transactionIndex,
edit as transactionEdit, edit as transactionEdit,
updateStatus as transactionUpdateStatus, updateStatus as transactionUpdateStatus,
items as transactionItems,
} from '@/routes/admin/manage/transactions'; } from '@/routes/admin/manage/transactions';
import type { Transaction, TransactionItem } from './columns'; import type { Transaction } from './columns';
import { TransactionCardRow } from './transaction-card'; import { TransactionCardRow } from './transaction-card';
import { TransactionItemSubRow } from './transaction-sub-row'; import { TransactionItemSubRow } from './transaction-sub-row';
import { TransactionSummaryCard } from './transaction-summary-card'; import { TransactionSummaryCard } from './transaction-summary-card';
@ -101,9 +100,7 @@ export default function TransactionIndex({
const { can } = useCan(); const { can } = useCan();
const { name: appName, address: appAddress } = usePage().props as unknown as { name: string; address: string }; const { name: appName, address: appAddress } = usePage().props as unknown as { name: string; address: string };
const [deleting, setDeleting] = useState<Transaction | null>(null); const [deleting, setDeleting] = useState<Transaction | null>(null);
const [loadedItems, setLoadedItems] = useState<Record<number, TransactionItem[]>>({}); const expand = useCardTableExpand(true);
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
const expand = useCardTableExpand(false);
const printer = useThermalPrinter(); const printer = useThermalPrinter();
const pagination = { const pagination = {
@ -152,29 +149,6 @@ export default function TransactionIndex({
[filterOptions.employees, filters.created_by_id], [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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -196,62 +170,44 @@ export default function TransactionIndex({
return; return;
} }
const printWithItems = (items: TransactionItem[]) => { const rupiah = (n: number) => `Rp ${n.toLocaleString('id-ID')}`;
const rupiah = (n: number) => `Rp ${n.toLocaleString('id-ID')}`;
const mappedItems = items.map((item) => ({ const items = (transaction.order_items ?? []).map((item) => ({
product_name: item.product_variant?.product?.name ?? '-', product_name: item.product_variant?.product?.name ?? '-',
variant_name: item.product_variant?.name ?? '-', variant_name: item.product_variant?.name ?? '-',
quantity: `${Number(item.quantity)}`, quantity: `${Number(item.quantity)}`,
unit_price: rupiah(Number(item.unit_price)), unit_price: rupiah(Number(item.unit_price)),
subtotal: rupiah(Number(item.subtotal)), subtotal: rupiah(Number(item.subtotal)),
})); }));
const cashierName = const cashierName =
transaction.created_by?.user_profile?.full_name ?? '-'; transaction.created_by?.user_profile?.full_name ?? '-';
const data = encodeOrderReceipt( const data = encodeOrderReceipt(
{ {
order_number: transaction.order_number, order_number: transaction.order_number,
created_at: format(new Date(transaction.created_at), 'dd/MM/yyyy HH:mm'), created_at: format(new Date(transaction.created_at), 'dd/MM/yyyy HH:mm'),
customer_name: transaction.customer?.name ?? null, customer_name: transaction.customer?.name ?? null,
cashier_name: cashierName, cashier_name: cashierName,
items: mappedItems, items,
subtotal: rupiah(Number(transaction.subtotal)), subtotal: rupiah(Number(transaction.subtotal)),
discount: rupiah(Number(transaction.discount)), discount: rupiah(Number(transaction.discount)),
nego_price: transaction.nego_price != null && Number(transaction.nego_price) > 0 nego_price: transaction.nego_price != null && Number(transaction.nego_price) > 0
? rupiah(Number(transaction.nego_price)) ? rupiah(Number(transaction.nego_price))
: null, : null,
total_amount: rupiah(Number(transaction.total_amount)), total_amount: rupiah(Number(transaction.total_amount)),
notes: transaction.notes ?? null, notes: transaction.notes ?? null,
}, },
{ {
storeName: appName, storeName: appName,
storeAddress: appAddress, storeAddress: appAddress,
paperWidth, paperWidth,
}, },
); );
printer.print(data).catch((err: Error) => { printer.print(data).catch((err: Error) => {
alert(`Gagal mencetak: ${err.message}`); 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 = ( const filterToolbar = (
@ -539,14 +495,7 @@ export default function TransactionIndex({
data={transactions.data} data={transactions.data}
getItemKey={(t) => t.id} getItemKey={(t) => t.id}
expandedKeys={expand.expandedKeys} expandedKeys={expand.expandedKeys}
onToggleExpand={(key) => { onToggleExpand={expand.toggleExpand}
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} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
@ -579,11 +528,7 @@ export default function TransactionIndex({
/> />
)} )}
renderSubContent={(transaction) => ( renderSubContent={(transaction) => (
<TransactionItemSubRow <TransactionItemSubRow transaction={transaction} />
transaction={transaction}
items={loadedItems[transaction.id] ?? []}
isLoading={loadingItems[transaction.id] ?? false}
/>
)} )}
/> />

View File

@ -47,10 +47,19 @@ export function TransactionCardRow({
onPrint, onPrint,
}: TransactionCardRowParams) { }: TransactionCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const variantCount = transaction.items_count ?? 0; const items = transaction.order_items ?? [];
const totalQty = transaction.total_qty ?? 0; const variantCount = items.length;
const productNamesStr = transaction.product_names ?? ''; const productNames = [
const productNames = productNamesStr ? productNamesStr.split(', ') : []; ...new Set(
items
.map((item) => item.product_variant?.product?.name)
.filter(Boolean),
),
];
const totalQty = items.reduce(
(sum, item) => sum + Number(item.quantity),
0,
);
const statusBadgeClass = const statusBadgeClass =
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending; STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;

View File

@ -9,18 +9,10 @@ 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 { Transaction, TransactionItem } from './columns'; import type { Transaction } from './columns';
export function TransactionItemSubRow({ export function TransactionItemSubRow({ transaction }: { transaction: Transaction }) {
transaction, const items = transaction.order_items ?? [];
items: loadedItems,
isLoading,
}: {
transaction: Transaction;
items: TransactionItem[];
isLoading: boolean;
}) {
const items = loadedItems ?? [];
return ( return (
<div className="space-y-4 overflow-x-auto"> <div className="space-y-4 overflow-x-auto">
@ -41,16 +33,7 @@ export function TransactionItemSubRow({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {items.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
Memuat item...
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={7} colSpan={7}

View File

@ -66,18 +66,14 @@
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}/materials', [CuttingController::class, 'materials'])->name('cuttings.materials')->middleware('permission:cuttings.view');
Route::get('cuttings/{cutting}/share', [CuttingController::class, 'share'])->name('cuttings.share'); 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'); Route::resource('transactions', TransactionController::class)->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::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'); 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 () {