Compare commits
4 Commits
ce901c2c8b
...
c2354440e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2354440e3 | ||
|
|
0344648b74 | ||
|
|
910be15abc | ||
|
|
113f9a7c4f |
@ -9,6 +9,7 @@
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Admin\Manage\CuttingService;
|
||||
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -93,4 +94,12 @@ public function destroy(Cutting $cutting): RedirectResponse
|
||||
'admin.manage.cuttings.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function materials(Cutting $cutting): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'materials' => $this->service->getMaterials($cutting),
|
||||
'combinations' => $this->service->getCombinations($cutting),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Admin\Manage\PurchaseService;
|
||||
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
|
||||
use App\Services\Admin\Master\SupplierService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -79,4 +80,11 @@ public function destroy(Purchase $purchase): RedirectResponse
|
||||
'admin.manage.purchases.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function items(Purchase $purchase): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'items' => $this->service->getItems($purchase),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Restock;
|
||||
use App\Services\Admin\Manage\RestockService;
|
||||
use App\Services\Admin\Master\Product\ProductVariantService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -72,4 +73,11 @@ public function destroy(Restock $restock): RedirectResponse
|
||||
'admin.manage.restocks.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function items(Restock $restock): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'items' => $this->service->getItems($restock),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -24,17 +24,21 @@ public function __construct(
|
||||
|
||||
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()
|
||||
->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at'])
|
||||
->with([
|
||||
'createdBy:id',
|
||||
'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) {
|
||||
$q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%"))
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
@ -52,23 +56,42 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
$cutting->photo_conversion_url = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb'))
|
||||
: 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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return CuttingResult::query()
|
||||
|
||||
@ -24,18 +24,22 @@ public function __construct(
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$itemCountQuery = '(SELECT COUNT(*) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)';
|
||||
$totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM purchase_items WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL)';
|
||||
$materialNameQuery = '(SELECT raw_materials.name FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)';
|
||||
$unitQuery = '(SELECT raw_materials.unit FROM purchase_items JOIN raw_material_prices ON raw_material_prices.id = purchase_items.raw_material_price_id JOIN raw_materials ON raw_materials.id = raw_material_prices.raw_material_id WHERE purchase_items.purchase_id = purchases.id AND purchase_items.deleted_at IS NULL LIMIT 1)';
|
||||
|
||||
$paginator = Purchase::query()
|
||||
->select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at'])
|
||||
->with([
|
||||
'supplier:id,name',
|
||||
'createdBy:id',
|
||||
'createdBy.userProfile:id,user_id,full_name',
|
||||
'purchaseItems' => fn ($q) => $q
|
||||
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
|
||||
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'),
|
||||
'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock',
|
||||
'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
])
|
||||
->selectRaw("{$itemCountQuery} as variants_count")
|
||||
->selectRaw("{$totalQtyQuery} as total_qty")
|
||||
->selectRaw("{$materialNameQuery} as material_name")
|
||||
->selectRaw("{$unitQuery} as unit")
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
@ -52,8 +56,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
$purchase->photo_conversion_url = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
|
||||
: null;
|
||||
});
|
||||
|
||||
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function getItems(Purchase $purchase): \Illuminate\Support\Collection
|
||||
{
|
||||
return $purchase->purchaseItems()
|
||||
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
|
||||
->with(['rawMaterialPrice:id,raw_material_id,variant,price,stock', 'rawMaterialPrice.rawMaterial:id,name,unit'])
|
||||
->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)')
|
||||
->get()
|
||||
->each(function (PurchaseItem $item) {
|
||||
if (! $item->rawMaterialPrice) {
|
||||
return;
|
||||
}
|
||||
@ -66,9 +81,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function getForCreate(): array
|
||||
|
||||
@ -25,17 +25,19 @@ public function __construct(
|
||||
|
||||
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()
|
||||
->select(['id', 'created_by_id', 'total', 'notes', 'stock_type', 'created_at'])
|
||||
->with([
|
||||
'createdBy:id',
|
||||
'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) {
|
||||
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
@ -43,8 +45,17 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
|
||||
$paginator->getCollection()->each(function (Restock $restock) {
|
||||
$restock->restockItems->each(function (RestockItem $item) {
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -57,9 +68,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function store(array $data): Restock
|
||||
|
||||
@ -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
|
||||
|
||||
82
docs/2026-08-14-lazy-loading-card-table.md
Normal file
82
docs/2026-08-14-lazy-loading-card-table.md
Normal file
@ -0,0 +1,82 @@
|
||||
# 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
|
||||
@ -33,35 +33,38 @@ export type Cutting = {
|
||||
photo_conversion_url: string | null;
|
||||
created_at: string;
|
||||
formatted_created_at: string;
|
||||
materials_count: number;
|
||||
total_usage: number;
|
||||
product_name: string | null;
|
||||
cutting_result: number | null;
|
||||
created_by: {
|
||||
id: number;
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
cutting_results: CuttingResult[];
|
||||
cutting_materials: {
|
||||
cutting_results?: CuttingResult[];
|
||||
cutting_materials?: CuttingMaterialDetail[];
|
||||
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;
|
||||
raw_material_price_id: number;
|
||||
material_usage: number;
|
||||
material_result: number | null;
|
||||
combination_id: number | null;
|
||||
raw_material_price: {
|
||||
variant: string;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
variant: string;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
}[];
|
||||
cutting_material_combinations: {
|
||||
id: number;
|
||||
material_result: number | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type CuttingForEdit = {
|
||||
|
||||
@ -105,22 +105,10 @@ export function CuttingCardRow({
|
||||
const { can } = useCan();
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const items = cutting.cutting_materials ?? [];
|
||||
const result = cutting.cutting_results?.[0];
|
||||
const singleCount = items.filter(
|
||||
(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 materialsCount = cutting.materials_count ?? 0;
|
||||
const totalUsage = cutting.total_usage ?? 0;
|
||||
const productName = cutting.product_name ?? '-';
|
||||
const cuttingResult = cutting.cutting_result ?? null;
|
||||
|
||||
const shareText = generateWhatsappText(cutting);
|
||||
|
||||
@ -161,9 +149,9 @@ export function CuttingCardRow({
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{materialCount > 0 && (
|
||||
{materialsCount > 0 && (
|
||||
<span>
|
||||
{materialCount} bahan baku
|
||||
{materialsCount} bahan baku
|
||||
</span>
|
||||
)}
|
||||
{cutting.description && (
|
||||
@ -193,12 +181,12 @@ export function CuttingCardRow({
|
||||
</span>
|
||||
{formatNumber(totalUsage)}
|
||||
</span>
|
||||
{result?.cutting_result && (
|
||||
{cuttingResult && (
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
Hasil:{' '}
|
||||
</span>
|
||||
{formatNumber(result.cutting_result)}
|
||||
{formatNumber(cuttingResult)}
|
||||
</span>
|
||||
)}
|
||||
{cutting.formatted_cost_per_unit && (
|
||||
|
||||
@ -9,11 +9,21 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { Fragment } from 'react';
|
||||
import type { Cutting } from './columns';
|
||||
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns';
|
||||
|
||||
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
||||
const items = cutting.cutting_materials ?? [];
|
||||
const combinations = cutting.cutting_material_combinations ?? [];
|
||||
export function CuttingItemSubRow({
|
||||
cutting,
|
||||
materials: loadedMaterials,
|
||||
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 comboItems = items.filter((item) => item.combination_id !== null);
|
||||
@ -67,220 +77,16 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Memuat bahan baku...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
@ -289,6 +95,221 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
||||
Tidak ada bahan baku.
|
||||
</TableCell>
|
||||
</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>
|
||||
</Table>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { CardTable } from '@/components/data-display';
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||
import { FilterPopover } from '@/components/data-display';
|
||||
@ -29,8 +29,9 @@ import {
|
||||
create as cuttingCreate,
|
||||
index as cuttingIndex,
|
||||
edit as cuttingEdit,
|
||||
materials as cuttingMaterials,
|
||||
} from '@/routes/admin/manage/cuttings';
|
||||
import type { Cutting } from './columns';
|
||||
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns';
|
||||
import { CuttingCardRow } from './cutting-card';
|
||||
import { CuttingItemSubRow } from './cutting-sub-row';
|
||||
|
||||
@ -59,7 +60,10 @@ export default function CuttingIndex({
|
||||
}: Props) {
|
||||
const { can } = useCan();
|
||||
const [deleting, setDeleting] = useState<Cutting | null>(null);
|
||||
const expand = useCardTableExpand(true);
|
||||
const [loadedMaterials, setLoadedMaterials] = useState<Record<number, CuttingMaterialDetail[]>>({});
|
||||
const [loadedCombinations, setLoadedCombinations] = useState<Record<number, CuttingCombination[]>>({});
|
||||
const [loadingMaterials, setLoadingMaterials] = useState<Record<number, boolean>>({});
|
||||
const expand = useCardTableExpand(false);
|
||||
|
||||
const pagination = {
|
||||
current_page: cuttings.current_page,
|
||||
@ -104,6 +108,34 @@ export default function CuttingIndex({
|
||||
[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 = (
|
||||
<FilterPopover
|
||||
open={filterOpen}
|
||||
@ -193,7 +225,14 @@ export default function CuttingIndex({
|
||||
data={cuttings.data}
|
||||
getItemKey={(c) => c.id}
|
||||
expandedKeys={expand.expandedKeys}
|
||||
onToggleExpand={expand.toggleExpand}
|
||||
onToggleExpand={(key) => {
|
||||
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}
|
||||
onSearchChange={handleSearchChange}
|
||||
|
||||
@ -229,7 +268,12 @@ export default function CuttingIndex({
|
||||
/>
|
||||
)}
|
||||
renderSubContent={(cutting) => (
|
||||
<CuttingItemSubRow cutting={cutting} />
|
||||
<CuttingItemSubRow
|
||||
cutting={cutting}
|
||||
materials={loadedMaterials[cutting.id] ?? []}
|
||||
combinations={loadedCombinations[cutting.id] ?? []}
|
||||
isLoading={loadingMaterials[cutting.id] ?? false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
export default function CuttingShow({ cutting }: Props) {
|
||||
const items = cutting.cutting_materials ?? [];
|
||||
const result = cutting.cutting_results?.[0];
|
||||
const totalUsage = items.reduce(
|
||||
const totalUsage = cutting.total_usage ?? items.reduce(
|
||||
(sum, item) => sum + Number(item.material_usage),
|
||||
0,
|
||||
);
|
||||
@ -161,7 +161,12 @@ export default function CuttingShow({ cutting }: Props) {
|
||||
</div>
|
||||
|
||||
<i className="text-xs md:hidden">Geser kesamping untuk melihat lebih banyak</i>
|
||||
<CuttingItemSubRow cutting={cutting} />
|
||||
<CuttingItemSubRow
|
||||
cutting={cutting}
|
||||
materials={cutting.cutting_materials ?? []}
|
||||
combinations={cutting.cutting_material_combinations ?? []}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,10 @@ export type Purchase = {
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
created_at: string;
|
||||
variants_count: number;
|
||||
total_qty: number;
|
||||
material_name: string | null;
|
||||
unit: string | null;
|
||||
supplier: {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -29,26 +33,28 @@ export type Purchase = {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
purchase_items: {
|
||||
purchase_items?: PurchaseItemDetail[];
|
||||
};
|
||||
|
||||
export type PurchaseItemDetail = {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
raw_material_price: {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
raw_material_price: {
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PurchaseForEdit = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { CardTable } from '@/components/data-display';
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||
import { FilterPopover } from '@/components/data-display';
|
||||
@ -22,8 +22,9 @@ import {
|
||||
create as purchaseCreate,
|
||||
edit as purchaseEdit,
|
||||
index as purchaseIndex,
|
||||
items as purchaseItems,
|
||||
} from '@/routes/admin/manage/purchases';
|
||||
import type { Purchase } from './columns';
|
||||
import type { Purchase, PurchaseItemDetail } from './columns';
|
||||
import { PurchaseCardRow } from './purchase-card';
|
||||
import { PurchaseItemSubRow } from './purchase-sub-row';
|
||||
|
||||
@ -51,7 +52,9 @@ export default function PurchaseIndex({
|
||||
}: Props) {
|
||||
const { can } = useCan();
|
||||
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
||||
const expand = useCardTableExpand(true);
|
||||
const [loadedItems, setLoadedItems] = useState<Record<number, PurchaseItemDetail[]>>({});
|
||||
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
|
||||
const expand = useCardTableExpand(false);
|
||||
|
||||
const pagination = {
|
||||
current_page: purchases.current_page,
|
||||
@ -84,6 +87,29 @@ export default function PurchaseIndex({
|
||||
[suppliers, filters.supplier_id],
|
||||
);
|
||||
|
||||
const fetchItems = useCallback((purchase: Purchase) => {
|
||||
if (loadedItems[purchase.id] || loadingItems[purchase.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingItems((prev) => ({ ...prev, [purchase.id]: true }));
|
||||
|
||||
fetch(purchaseItems.url(purchase.id))
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setLoadedItems((prev) => ({
|
||||
...prev,
|
||||
[purchase.id]: data.items ?? [],
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadedItems((prev) => ({ ...prev, [purchase.id]: [] }));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingItems((prev) => ({ ...prev, [purchase.id]: false }));
|
||||
});
|
||||
}, [loadedItems, loadingItems]);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -161,7 +187,14 @@ export default function PurchaseIndex({
|
||||
data={purchases.data}
|
||||
getItemKey={(p) => p.id}
|
||||
expandedKeys={expand.expandedKeys}
|
||||
onToggleExpand={expand.toggleExpand}
|
||||
onToggleExpand={(key) => {
|
||||
const p = purchases.data.find((r) => r.id === key);
|
||||
const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key);
|
||||
if (p && !isCurrentlyExpanded) {
|
||||
fetchItems(p);
|
||||
}
|
||||
expand.toggleExpand(key);
|
||||
}}
|
||||
searchValue={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
|
||||
@ -192,7 +225,11 @@ export default function PurchaseIndex({
|
||||
/>
|
||||
)}
|
||||
renderSubContent={(purchase) => (
|
||||
<PurchaseItemSubRow purchase={purchase} />
|
||||
<PurchaseItemSubRow
|
||||
purchase={purchase}
|
||||
items={loadedItems[purchase.id] ?? []}
|
||||
isLoading={loadingItems[purchase.id] ?? false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@ -26,15 +26,10 @@ export function PurchaseCardRow({
|
||||
onDelete,
|
||||
}: PurchaseCardRowParams) {
|
||||
const { can } = useCan();
|
||||
const items = purchase.purchase_items ?? [];
|
||||
const variantCount = items.length;
|
||||
const rawMaterialName =
|
||||
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
const totalQty = items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity),
|
||||
0,
|
||||
);
|
||||
const variantCount = purchase.variants_count ?? 0;
|
||||
const totalQty = purchase.total_qty ?? 0;
|
||||
const rawMaterialName = purchase.material_name ?? '-';
|
||||
const unit = purchase.unit ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -9,11 +9,18 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Purchase } from './columns';
|
||||
import type { Purchase, PurchaseItemDetail } from './columns';
|
||||
|
||||
export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||
const items = purchase.purchase_items ?? [];
|
||||
const unit = items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
export function PurchaseItemSubRow({
|
||||
purchase,
|
||||
items: loadedItems,
|
||||
isLoading,
|
||||
}: {
|
||||
purchase: Purchase;
|
||||
items: PurchaseItemDetail[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const unit = purchase.unit ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
@ -31,7 +38,16 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Memuat item...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : loadedItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
@ -41,7 +57,7 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
loadedItems.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
{index + 1}
|
||||
|
||||
@ -25,13 +25,16 @@ export type Restock = {
|
||||
notes: string | null;
|
||||
stock_type: RestockStockType;
|
||||
created_at: string;
|
||||
items_count: number;
|
||||
total_qty: number;
|
||||
product_names: string | null;
|
||||
created_by: {
|
||||
id: number;
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
restock_items: RestockItem[];
|
||||
restock_items?: RestockItem[];
|
||||
};
|
||||
|
||||
export type RestockForEdit = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { CardTable } from '@/components/data-display';
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
@ -13,8 +13,9 @@ import {
|
||||
create as restockCreate,
|
||||
index as restockIndex,
|
||||
edit as restockEdit,
|
||||
items as restockItems,
|
||||
} from '@/routes/admin/manage/restocks';
|
||||
import type { Restock } from './columns';
|
||||
import type { Restock, RestockItem } from './columns';
|
||||
import { RestockCardRow } from './restock-card';
|
||||
import { RestockItemSubRow } from './restock-sub-row';
|
||||
|
||||
@ -31,7 +32,9 @@ type Props = {
|
||||
export default function RestockIndex({ restocks }: Props) {
|
||||
const { can } = useCan();
|
||||
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 = {
|
||||
current_page: restocks.current_page,
|
||||
@ -50,6 +53,29 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
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() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -83,7 +109,14 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
data={restocks.data}
|
||||
getItemKey={(r) => r.id}
|
||||
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}
|
||||
onSearchChange={handleSearchChange}
|
||||
|
||||
@ -113,7 +146,11 @@ export default function RestockIndex({ restocks }: Props) {
|
||||
/>
|
||||
)}
|
||||
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,
|
||||
}: RestockCardRowParams) {
|
||||
const { can } = useCan();
|
||||
const items = restock.restock_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 = restock.items_count ?? 0;
|
||||
const totalQty = restock.total_qty ?? 0;
|
||||
const productNamesStr = restock.product_names ?? '';
|
||||
const productNames = productNamesStr ? productNamesStr.split(', ') : [];
|
||||
const stockTypeConfig =
|
||||
STOCK_TYPE_CONFIG[restock.stock_type] ?? STOCK_TYPE_CONFIG.good;
|
||||
|
||||
|
||||
@ -10,10 +10,18 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { Restock } from './columns';
|
||||
import type { Restock, RestockItem } from './columns';
|
||||
|
||||
export function RestockItemSubRow({ restock }: { restock: Restock }) {
|
||||
const items = restock.restock_items ?? [];
|
||||
export function RestockItemSubRow({
|
||||
restock,
|
||||
items: loadedItems,
|
||||
isLoading,
|
||||
}: {
|
||||
restock: Restock;
|
||||
items: RestockItem[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const items = loadedItems ?? [];
|
||||
|
||||
const groupedByProduct = items.reduce(
|
||||
(acc, item) => {
|
||||
@ -50,7 +58,16 @@ acc[name] = [];
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Memuat item...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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<Transaction | null>(null);
|
||||
const expand = useCardTableExpand(true);
|
||||
const [loadedItems, setLoadedItems] = useState<Record<number, TransactionItem[]>>({});
|
||||
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
|
||||
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) => (
|
||||
<TransactionItemSubRow transaction={transaction} />
|
||||
<TransactionItemSubRow
|
||||
transaction={transaction}
|
||||
items={loadedItems[transaction.id] ?? []}
|
||||
isLoading={loadingItems[transaction.id] ?? false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
@ -33,7 +41,16 @@ export function TransactionItemSubRow({ transaction }: { transaction: Transactio
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Memuat item...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
|
||||
@ -66,14 +66,18 @@
|
||||
|
||||
Route::prefix('manage')->name('admin.manage.')->group(function () {
|
||||
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchases.view|purchases.create|purchases.update|purchases.delete');
|
||||
Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view');
|
||||
|
||||
Route::resource('cuttings', CuttingController::class)->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete');
|
||||
Route::get('cuttings/{cutting}/materials', [CuttingController::class, 'materials'])->name('cuttings.materials')->middleware('permission:cuttings.view');
|
||||
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::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::get('restocks/{restock}/items', [RestockController::class, 'items'])->name('restocks.items')->middleware('permission:restocks.view');
|
||||
});
|
||||
|
||||
Route::prefix('finance')->name('admin.finance.')->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user