feat: implement lazy loading for cutting materials and combinations, optimize data fetching in Cutting service and controller
This commit is contained in:
parent
113f9a7c4f
commit
910be15abc
@ -9,6 +9,7 @@
|
|||||||
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;
|
||||||
@ -93,4 +94,12 @@ 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
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}%");
|
||||||
@ -52,23 +56,42 @@ 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()
|
||||||
|
|||||||
@ -4,14 +4,14 @@ ## Date
|
|||||||
2026-08-14
|
2026-08-14
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Optimize page load performance for raw materials, products, and purchases list pages by implementing lazy loading for child/variant data in card-based layouts.
|
Optimize page load performance for raw materials, products, purchases, and cutting list pages by implementing lazy loading for child/variant data in card-based layouts.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
Pages with 100+ records loaded all child data eagerly (variants, items), causing slow initial page loads.
|
Pages with 100+ records loaded all child data eagerly (variants, items, materials), causing slow initial page loads.
|
||||||
|
|
||||||
## Solution Pattern
|
## Solution Pattern
|
||||||
1. **Backend**: Remove eager-loaded relationships from paginated query
|
1. **Backend**: Remove eager-loaded relationships from paginated query
|
||||||
2. **Backend**: Add SQL subqueries for summary data (`variants_count`, `total_qty`, `material_name`, `unit`)
|
2. **Backend**: Add SQL subqueries for summary data (`variants_count`, `total_qty`, `material_name`, `unit`, `materials_count`, `total_usage`, `product_name`, `cutting_result`)
|
||||||
3. **Backend**: Add new controller method returning JSON for lazy load
|
3. **Backend**: Add new controller method returning JSON for lazy load
|
||||||
4. **Frontend**: Use `useCardTableExpand(false)` for default collapsed state
|
4. **Frontend**: Use `useCardTableExpand(false)` for default collapsed state
|
||||||
5. **Frontend**: Fetch child data via `fetch()` on expand, store in `Record<number, T[]>` state
|
5. **Frontend**: Fetch child data via `fetch()` on expand, store in `Record<number, T[]>` state
|
||||||
@ -19,6 +19,16 @@ ## Solution Pattern
|
|||||||
|
|
||||||
## Files Modified
|
## Files Modified
|
||||||
|
|
||||||
|
### 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)
|
### Purchases (this session)
|
||||||
- `app/Services/Admin/Manage/PurchaseService.php` - `paginated()` no longer eager loads `purchaseItems`; added `getItems()` method
|
- `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
|
- `app/Http/Controllers/Admin/Manage/PurchaseController.php` - added `items()` method
|
||||||
@ -32,18 +42,20 @@ ### Raw Materials & Products (previous sessions)
|
|||||||
- Same pattern applied to `RawMaterialService`, `RawMaterialController`, `ProductService`, `ProductController`
|
- Same pattern applied to `RawMaterialService`, `RawMaterialController`, `ProductService`, `ProductController`
|
||||||
- Frontend pages updated with lazy loading state
|
- Frontend pages updated with lazy loading state
|
||||||
|
|
||||||
## Route Added
|
## Routes Added
|
||||||
```php
|
```php
|
||||||
Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view');
|
Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view');
|
||||||
|
Route::get('cuttings/{cutting}/materials', [CuttingController::class, 'materials'])->name('cuttings.materials')->middleware('permission:cuttings.view');
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
- SQL subqueries avoid N+1 queries while keeping summary data in the paginated response
|
- SQL subqueries avoid N+1 queries while keeping summary data in the paginated response
|
||||||
- `getItems()` method handles media URL resolution for item photos
|
- `getItems()` / `getMaterials()` / `getCombinations()` methods handle media URL resolution for photos
|
||||||
- Frontend caches loaded items to avoid re-fetching on repeated expand/collapse
|
- Frontend caches loaded items to avoid re-fetching on repeated expand/collapse
|
||||||
- Loading state shown while items are being fetched
|
- Loading state shown while items are being fetched
|
||||||
|
- Show pages (e.g., `show.tsx`) pass full data directly to sub-row components
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- PHP syntax check: OK
|
- PHP syntax check: OK
|
||||||
- TypeScript: Only pre-existing errors in `use-purchase-draft.ts` (unrelated)
|
- TypeScript: Only pre-existing errors in `use-*-draft.ts` files (unrelated)
|
||||||
- Route cache cleared
|
- Route cache cleared
|
||||||
|
|||||||
@ -33,35 +33,38 @@ 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: {
|
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;
|
id: number;
|
||||||
raw_material_price_id: number;
|
variant: string;
|
||||||
material_usage: number;
|
photo_url: string | null;
|
||||||
material_result: number | null;
|
photo_conversion_url: string | null;
|
||||||
combination_id: number | null;
|
raw_material: {
|
||||||
raw_material_price: {
|
|
||||||
id: number;
|
id: number;
|
||||||
variant: string;
|
name: string;
|
||||||
photo_url: string | null;
|
unit: string;
|
||||||
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 = {
|
||||||
|
|||||||
@ -105,22 +105,10 @@ 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 items = cutting.cutting_materials ?? [];
|
const materialsCount = cutting.materials_count ?? 0;
|
||||||
const result = cutting.cutting_results?.[0];
|
const totalUsage = cutting.total_usage ?? 0;
|
||||||
const singleCount = items.filter(
|
const productName = cutting.product_name ?? '-';
|
||||||
(item) => item.combination_id === null,
|
const cuttingResult = cutting.cutting_result ?? 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);
|
||||||
|
|
||||||
@ -161,9 +149,9 @@ export function CuttingCardRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{materialCount > 0 && (
|
{materialsCount > 0 && (
|
||||||
<span>
|
<span>
|
||||||
{materialCount} bahan baku
|
{materialsCount} bahan baku
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cutting.description && (
|
{cutting.description && (
|
||||||
@ -193,12 +181,12 @@ export function CuttingCardRow({
|
|||||||
</span>
|
</span>
|
||||||
{formatNumber(totalUsage)}
|
{formatNumber(totalUsage)}
|
||||||
</span>
|
</span>
|
||||||
{result?.cutting_result && (
|
{cuttingResult && (
|
||||||
<span>
|
<span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Hasil:{' '}
|
Hasil:{' '}
|
||||||
</span>
|
</span>
|
||||||
{formatNumber(result.cutting_result)}
|
{formatNumber(cuttingResult)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cutting.formatted_cost_per_unit && (
|
{cutting.formatted_cost_per_unit && (
|
||||||
|
|||||||
@ -9,11 +9,21 @@ 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 } from './columns';
|
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns';
|
||||||
|
|
||||||
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
export function CuttingItemSubRow({
|
||||||
const items = cutting.cutting_materials ?? [];
|
cutting,
|
||||||
const combinations = cutting.cutting_material_combinations ?? [];
|
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 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);
|
||||||
@ -67,220 +77,16 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{hasSingle && (
|
{isLoading ? (
|
||||||
<>
|
<TableRow>
|
||||||
<TableRow>
|
<TableCell
|
||||||
<TableCell
|
colSpan={5}
|
||||||
colSpan={5}
|
className="text-center text-muted-foreground"
|
||||||
className="text-center font-bold bg-muted/50"
|
>
|
||||||
>
|
Memuat bahan baku...
|
||||||
Single
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
) : items.length === 0 ? (
|
||||||
{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}
|
||||||
@ -289,6 +95,221 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
|
|||||||
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>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { CardTable } from '@/components/data-display';
|
import { CardTable } from '@/components/data-display';
|
||||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||||
import { FilterPopover } from '@/components/data-display';
|
import { FilterPopover } from '@/components/data-display';
|
||||||
@ -29,8 +29,9 @@ 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 } from './columns';
|
import type { Cutting, CuttingMaterialDetail, CuttingCombination } from './columns';
|
||||||
import { CuttingCardRow } from './cutting-card';
|
import { CuttingCardRow } from './cutting-card';
|
||||||
import { CuttingItemSubRow } from './cutting-sub-row';
|
import { CuttingItemSubRow } from './cutting-sub-row';
|
||||||
|
|
||||||
@ -59,7 +60,10 @@ 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 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 = {
|
const pagination = {
|
||||||
current_page: cuttings.current_page,
|
current_page: cuttings.current_page,
|
||||||
@ -104,6 +108,34 @@ 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}
|
||||||
@ -193,7 +225,14 @@ 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={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}
|
searchValue={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|
||||||
@ -229,7 +268,12 @@ export default function CuttingIndex({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderSubContent={(cutting) => (
|
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) {
|
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 = items.reduce(
|
const totalUsage = cutting.total_usage ?? items.reduce(
|
||||||
(sum, item) => sum + Number(item.material_usage),
|
(sum, item) => sum + Number(item.material_usage),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -161,7 +161,12 @@ 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 cutting={cutting} />
|
<CuttingItemSubRow
|
||||||
|
cutting={cutting}
|
||||||
|
materials={cutting.cutting_materials ?? []}
|
||||||
|
combinations={cutting.cutting_material_combinations ?? []}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -69,6 +69,7 @@
|
|||||||
Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view');
|
Route::get('purchases/{purchase}/items', [PurchaseController::class, 'items'])->name('purchases.items')->middleware('permission:purchases.view');
|
||||||
|
|
||||||
Route::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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user