diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index 816889f..f34709c 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -8,6 +8,7 @@ use App\Http\Requests\PaginatedRequest; use App\Models\Cutting; use App\Services\Admin\Manage\CuttingService; +use App\Services\Admin\Master\RawMaterial\RawMaterialService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -18,6 +19,7 @@ class CuttingController extends Controller { public function __construct( private CuttingService $service, + private RawMaterialService $rawMaterialService, private RawMaterialVariantService $rawMaterialVariantService, ) {} @@ -40,7 +42,7 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/cutting/create', [ - 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), + 'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), ]); } @@ -72,7 +74,7 @@ public function edit(Cutting $cutting): Response { return Inertia::render('admin/manage/cutting/edit', [ 'cutting' => $this->service->getForEdit($cutting), - 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), + 'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), ]); } diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php index 0519e1c..f0aa679 100644 --- a/app/Http/Controllers/Admin/Manage/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -7,6 +7,7 @@ use App\Http\Requests\PaginatedRequest; use App\Models\Purchase; use App\Services\Admin\Manage\PurchaseService; +use App\Services\Admin\Master\RawMaterial\RawMaterialService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; use App\Services\Admin\Master\SupplierService; use Illuminate\Http\JsonResponse; @@ -19,6 +20,7 @@ class PurchaseController extends Controller public function __construct( private readonly PurchaseService $service, private readonly SupplierService $supplierService, + private readonly RawMaterialService $rawMaterialService, private readonly RawMaterialVariantService $rawMaterialVariantService, ) {} @@ -39,7 +41,7 @@ public function create(): Response { return Inertia::render('admin/manage/purchase/create', [ 'suppliers' => $this->supplierService->getAll(), - 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), + 'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), ]); } @@ -58,7 +60,7 @@ public function edit(Purchase $purchase): Response return Inertia::render('admin/manage/purchase/edit', [ 'purchase' => $this->service->getForEdit($purchase), 'suppliers' => $this->supplierService->getAll(), - 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), + 'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), ]); } diff --git a/app/Http/Controllers/Admin/Manage/RestockController.php b/app/Http/Controllers/Admin/Manage/RestockController.php index 7d5259b..bf24539 100644 --- a/app/Http/Controllers/Admin/Manage/RestockController.php +++ b/app/Http/Controllers/Admin/Manage/RestockController.php @@ -7,6 +7,7 @@ use App\Http\Requests\PaginatedRequest; use App\Models\Restock; use App\Services\Admin\Manage\RestockService; +use App\Services\Admin\Master\Product\ProductService; use App\Services\Admin\Master\Product\ProductVariantService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -17,6 +18,7 @@ class RestockController extends Controller { public function __construct( private RestockService $service, + private ProductService $productService, private ProductVariantService $productVariantService, ) {} @@ -33,7 +35,7 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/restock/create', [ - 'products' => $this->productVariantService->getForRestock(), + 'products' => $this->productService->getActiveProducts(), ]); } @@ -51,7 +53,7 @@ public function edit(Restock $restock): Response { return Inertia::render('admin/manage/restock/edit', [ 'restock' => $this->service->getForEdit($restock), - 'products' => $this->productVariantService->getForRestock(), + 'products' => $this->productService->getActiveProducts(), ]); } diff --git a/app/Http/Controllers/Admin/Manage/TransactionController.php b/app/Http/Controllers/Admin/Manage/TransactionController.php index 84c965a..2fafb51 100644 --- a/app/Http/Controllers/Admin/Manage/TransactionController.php +++ b/app/Http/Controllers/Admin/Manage/TransactionController.php @@ -13,6 +13,7 @@ use App\Models\User; use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Master\CustomerService; +use App\Services\Admin\Master\Product\ProductService; use App\Services\Admin\Master\Product\ProductVariantService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -23,6 +24,7 @@ class TransactionController extends Controller { public function __construct( private TransactionService $service, + private ProductService $productService, private ProductVariantService $productVariantService, private CustomerService $customerService, ) {} @@ -50,7 +52,7 @@ public function create(): Response $isCashier = $user->hasRole(Role::CASHIER); return Inertia::render('admin/manage/transaction/create', [ - 'products' => $this->productVariantService->getForTransaction(), + 'products' => $this->productService->getActiveProducts(), 'customers' => $this->customerService->getAll(), 'employees' => $this->getEmployees(), 'channelOptions' => OrderChannel::toSelect(), @@ -80,7 +82,7 @@ public function edit(Order $transaction): Response return Inertia::render('admin/manage/transaction/edit', [ 'transaction' => $this->service->getForEdit($transaction), - 'products' => $this->productVariantService->getForTransaction(), + 'products' => $this->productService->getActiveProducts(), 'customers' => $this->customerService->getAll(), 'employees' => $this->getEmployees(), 'channelOptions' => OrderChannel::toSelect(), diff --git a/app/Http/Controllers/Admin/Master/Product/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php index 4a627f7..48c6e69 100644 --- a/app/Http/Controllers/Admin/Master/Product/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -133,4 +133,11 @@ public function variants(Product $product): JsonResponse 'variants' => $this->service->getVariants($product), ]); } + + public function activeProducts(): JsonResponse + { + return response()->json([ + 'products' => $this->service->getActiveProducts(), + ]); + } } diff --git a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php index 086cf59..6a91c64 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php @@ -92,4 +92,11 @@ public function variants(RawMaterial $rawMaterial): JsonResponse 'variants' => $this->service->getVariants($rawMaterial), ]); } + + public function activeMaterials(): JsonResponse + { + return response()->json([ + 'materials' => $this->service->getActiveMaterials(), + ]); + } } diff --git a/app/Services/Admin/Manage/CuttingService.php b/app/Services/Admin/Manage/CuttingService.php index bfbb98b..b940f97 100644 --- a/app/Services/Admin/Manage/CuttingService.php +++ b/app/Services/Admin/Manage/CuttingService.php @@ -257,6 +257,12 @@ public function getForEdit(Cutting $cutting): array 'combinations' => $combinations, 'photo_keys' => $photoKeys, 'photo_urls' => $photoUrls, + 'existing_material_ids' => $cutting->cuttingMaterials + ->map(fn (CuttingMaterial $m) => $m->rawMaterialPrice?->raw_material_id) + ->filter() + ->unique() + ->values() + ->all(), ]; } diff --git a/app/Services/Admin/Manage/PurchaseService.php b/app/Services/Admin/Manage/PurchaseService.php index cd9563d..f362205 100644 --- a/app/Services/Admin/Manage/PurchaseService.php +++ b/app/Services/Admin/Manage/PurchaseService.php @@ -174,6 +174,7 @@ public function getForEdit(Purchase $purchase): array 'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing', 'existing_material_name' => $materials->first()->name ?? null, 'existing_quantities' => $existingQuantities, + 'existing_material_ids' => $materials->pluck('id')->values()->all(), ]; } diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php index 5b38690..5eb3009 100644 --- a/app/Services/Admin/Manage/RestockService.php +++ b/app/Services/Admin/Manage/RestockService.php @@ -66,7 +66,7 @@ public function getForEdit(Restock $restock): array $restock->load([ 'restockItems' => fn ($q) => $q ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']), - 'restockItems.productVariant:id,name', + 'restockItems.productVariant:id,product_id,name', ]); $media = $restock->getFirstMedia('photos'); @@ -83,6 +83,12 @@ public function getForEdit(Restock $restock): array 'quantity' => $item->quantity, 'unit_price' => $item->unit_price, ]), + 'existing_product_ids' => $restock->restockItems + ->map(fn ($item) => $item->productVariant?->product_id) + ->filter() + ->unique() + ->values() + ->all(), ]; } diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index d756eae..c34ac31 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -107,6 +107,7 @@ public function getForEdit(Order $order): array { $order->load([ 'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price', + 'orderItems.productVariant:id,product_id,name', ]); $stockType = $order->orderItems->first()?->stock_quality?->value ?? 'good'; @@ -138,6 +139,12 @@ public function getForEdit(Order $order): array 'quantity' => $item->quantity, 'unit_price' => $item->unit_price, ]), + 'existing_product_ids' => $order->orderItems + ->map(fn (OrderItem $item) => $item->productVariant?->product_id) + ->filter() + ->unique() + ->values() + ->all(), ]; } diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index e37809a..e1136f6 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -66,6 +66,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->paginate($perPage); } + public function getActiveProducts(): \Illuminate\Support\Collection + { + return Product::select(['id', 'name']) + ->active() + ->orderBy('name') + ->get(); + } + public function getVariants(Product $product): Collection { return $product->productVariants() diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 6e2a2b8..4362c22 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -60,6 +60,36 @@ public function getForStokOpname(): array })->toArray(); } + public function getActiveProducts(): \Illuminate\Support\Collection + { + return Product::select(['id', 'name']) + ->active() + ->orderBy('name') + ->get(); + } + + public function getVariantsByProduct(Product $product): \Illuminate\Support\Collection + { + return $product->productVariants() + ->select(['id', 'product_id', 'name', 'stock', 'reject_stock', 'retail_stock']) + ->with(['productPrices:id,variant_id,type,price', 'media']) + ->get() + ->each(function (ProductVariant $variant) { + $media = $variant->getFirstMedia('images'); + $variant->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->getPath()) + : null; + $variant->photo_conversion_url = $media + ? ($media->getGeneratedConversions()->contains('thumb') + ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) + : $this->s3Service->getTemporaryUrl($media->getPath())) + : null; + + $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); + $variant->prices = $prices; + }); + } + public function getForRestock(): array { $products = Product::query() diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php index beaf5c6..0ac3160 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php @@ -57,6 +57,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->paginate($perPage); } + public function getActiveMaterials(): Collection + { + return RawMaterial::select(['id', 'name', 'unit']) + ->active() + ->orderBy('name') + ->get(); + } + public function getVariants(RawMaterial $rawMaterial): Collection { return $rawMaterial->rawMaterialPrices() diff --git a/resources/js/pages/admin/manage/cutting/columns.tsx b/resources/js/pages/admin/manage/cutting/columns.tsx index 4c8a521..cccfefb 100644 --- a/resources/js/pages/admin/manage/cutting/columns.tsx +++ b/resources/js/pages/admin/manage/cutting/columns.tsx @@ -91,6 +91,7 @@ export type CuttingForEdit = { }[]; photo_keys: string[]; photo_urls: string[]; + existing_material_ids: number[]; }; export type CuttingCreateData = { @@ -98,14 +99,15 @@ export type CuttingCreateData = { id: number; name: string; unit: string; - is_active: boolean; - raw_material_prices: { - id: number; - variant: string; - price: number; - stock: number; - photo_url: string | null; - photo_conversion_url: string | null; - }[]; }[]; }; + +export type RawMaterialVariant = { + id: number; + raw_material_id: number; + variant: string; + price: number; + stock: number; + photo_url: string | null; + photo_conversion_url: string | null; +}; diff --git a/resources/js/pages/admin/manage/cutting/create.tsx b/resources/js/pages/admin/manage/cutting/create.tsx index 48c4311..11df96c 100644 --- a/resources/js/pages/admin/manage/cutting/create.tsx +++ b/resources/js/pages/admin/manage/cutting/create.tsx @@ -2,7 +2,7 @@ import { Form, Head, Link, usePage } from '@inertiajs/react'; import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/dialogs'; import { FileUploadMultiple } from '@/components/inputs'; @@ -23,7 +23,7 @@ import { formatNumber } from '@/lib/format'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings'; -import type { CuttingCreateData } from './columns'; +import type { CuttingCreateData, RawMaterialVariant } from './columns'; type MaterialState = { raw_material_price_id: number; @@ -79,6 +79,8 @@ export default function CuttingCreate({ rawMaterials }: Props) { }); const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? ''); const [variantSearch, setVariantSearch] = useState(''); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const [productName, setProductName] = useState(draft?.productName ?? ''); const [sample, setSample] = useState(draft?.sample ?? 0); @@ -148,32 +150,87 @@ export default function CuttingCreate({ rawMaterials }: Props) { const materialsRef = useRef(materials); materialsRef.current = materials; - const priceMap = useMemo( - () => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))), - [rawMaterials], - ); + const priceMap = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const p of variants) { + map.set(p.id, p); + } + } + return map; + }, [fetchedVariants]); const selectedMaterial = useMemo( () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, [rawMaterials, selectedMaterialName], ); + const selectedMaterialVariants = useMemo( + () => (selectedMaterial ? fetchedVariants.get(selectedMaterial.id) ?? [] : []), + [selectedMaterial, fetchedVariants], + ); + + useEffect(() => { + if (!selectedMaterial) return; + if (fetchedVariants.has(selectedMaterial.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/raw-materials/${selectedMaterial.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedMaterial.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedMaterial, fetchedVariants]); + + const comboMaterialVariants = useMemo( + () => (comboMaterial ? fetchedVariants.get(comboMaterial.id) ?? [] : []), + [comboMaterial, fetchedVariants], + ); + + useEffect(() => { + if (!comboMaterial) return; + if (fetchedVariants.has(comboMaterial.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/raw-materials/${comboMaterial.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(comboMaterial.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [comboMaterial, fetchedVariants]); + const groupedVariants = useMemo(() => { if (!variantSearch) return []; + if (!selectedMaterial) return []; const search = variantSearch.toLowerCase(); - return rawMaterials - .map((rm) => ({ - ...rm, - raw_material_prices: rm.raw_material_prices.filter( - (p) => p.variant.toLowerCase().includes(search), - ), - })) - .filter((rm) => rm.raw_material_prices.length > 0); - }, [rawMaterials, variantSearch]); + return [{ + ...selectedMaterial, + raw_material_prices: selectedMaterialVariants.filter( + (p) => p.variant.toLowerCase().includes(search), + ), + }]; + }, [selectedMaterial, selectedMaterialVariants, variantSearch]); const addVariant = useCallback( - (rawMaterial: (typeof rawMaterials)[number], priceId: number) => { - const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId); + (rawMaterial: { id: number; name: string; unit: string }, priceId: number) => { + const variants = fetchedVariants.get(rawMaterial.id) ?? []; + const price = variants.find((p) => p.id === priceId); if (!price) { return; @@ -200,7 +257,7 @@ export default function CuttingCreate({ rawMaterials }: Props) { ]; }); }, - [], + [fetchedVariants], ); const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => { @@ -223,30 +280,22 @@ export default function CuttingCreate({ rawMaterials }: Props) { const comboIndex = combinations.length; + const comboMaterialData = rawMaterials.find((m) => m.name === comboMaterialName); + const variants = comboMaterialData ? (fetchedVariants.get(comboMaterialData.id) ?? []) : []; + const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { - let foundMaterial: typeof rawMaterials[number] | undefined; - let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined; - - for (const rm of rawMaterials) { - const p = rm.raw_material_prices.find((pp) => pp.id === priceId); - - if (p) { - foundMaterial = rm; - foundPrice = p; - break; - } - } + const price = variants.find((p) => p.id === priceId); return { raw_material_price_id: priceId, material_usage: '0', material_result: 0, combination_id: comboIndex, - variant: foundPrice?.variant ?? '', - material_name: foundMaterial?.name ?? '', - unit: foundMaterial?.unit ?? '', - photo_url: foundPrice?.photo_url ?? null, - photo_conversion_url: foundPrice?.photo_conversion_url ?? null, + variant: price?.variant ?? '', + material_name: comboMaterialData?.name ?? '', + unit: comboMaterialData?.unit ?? '', + photo_url: price?.photo_url ?? null, + photo_conversion_url: price?.photo_conversion_url ?? null, }; }); @@ -262,7 +311,7 @@ export default function CuttingCreate({ rawMaterials }: Props) { setComboMaterialName(''); setComboSelectedPriceIds([]); setComboResult(0); - }, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]); + }, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]); const removeMaterial = useCallback((index: number) => { setDeleteMaterialIndex(index); @@ -434,43 +483,53 @@ export default function CuttingCreate({ rawMaterials }: Props) {

Tidak ada varian ditemukan.

)} - {!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && ( + {!variantSearch && selectedMaterial && (
-
- {selectedMaterial.raw_material_prices.map((price) => { - const isAdded = materials.some((m) => m.raw_material_price_id === price.id); - const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedMaterialVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( +
+ {selectedMaterialVariants.map((price) => { + const isAdded = materials.some((m) => m.raw_material_price_id === price.id); + const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; - return ( -
-
- {price.photo_conversion_url ?? price.photo_url ? ( - {price.variant} - ) : ( -
N/A
- )} -
-

{price.variant}

-

- Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)} - {addedCount > 0 && ` · ×${addedCount}`} -

+ return ( +
+
+ {price.photo_conversion_url ?? price.photo_url ? ( + {price.variant} + ) : ( +
N/A
+ )} +
+

{price.variant}

+

+ Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)} + {addedCount > 0 && ` · ×${addedCount}`} +

+
+
+
+ +
-
- - -
-
- ); - })} -
+ ); + })} +
+ )}
)} @@ -740,18 +799,13 @@ export default function CuttingCreate({ rawMaterials }: Props) {
{comboSelectedPriceIds.map((priceId) => { let variantName = ''; - let materialName = ''; + let materialName = comboMaterialName; let photoUrl: string | null = null; - for (const rm of rawMaterials) { - const p = rm.raw_material_prices.find((pp) => pp.id === priceId); - - if (p) { - variantName = p.variant; - materialName = rm.name; - photoUrl = p.photo_conversion_url ?? p.photo_url; - break; - } + const price = comboMaterialVariants.find((pp) => pp.id === priceId); + if (price) { + variantName = price.variant; + photoUrl = price.photo_conversion_url ?? price.photo_url; } return ( @@ -777,33 +831,43 @@ export default function CuttingCreate({ rawMaterials }: Props) {
)} - {comboMaterial && comboMaterial.raw_material_prices.length > 0 && ( + {comboMaterial && (
-
- {comboMaterial.raw_material_prices.map((price) => { - const isSelected = comboSelectedPriceIds.includes(price.id); + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : comboMaterialVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( +
+ {comboMaterialVariants.map((price) => { + const isSelected = comboSelectedPriceIds.includes(price.id); - return ( -
-
- {price.photo_conversion_url ?? price.photo_url ? ( - {price.variant} - ) : ( -
N/A
- )} -
-

{price.variant}

-

Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}

+ return ( +
+
+ {price.photo_conversion_url ?? price.photo_url ? ( + {price.variant} + ) : ( +
N/A
+ )} +
+

{price.variant}

+

Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}

+
+
- -
- ); - })} -
+ ); + })} +
+ )}
)}
diff --git a/resources/js/pages/admin/manage/cutting/edit.tsx b/resources/js/pages/admin/manage/cutting/edit.tsx index 022303a..18b9294 100644 --- a/resources/js/pages/admin/manage/cutting/edit.tsx +++ b/resources/js/pages/admin/manage/cutting/edit.tsx @@ -2,7 +2,7 @@ import { Form, Head, Link, usePage } from '@inertiajs/react'; import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/dialogs'; import { FileUploadMultiple } from '@/components/inputs'; @@ -21,7 +21,7 @@ import { formatNumber } from '@/lib/format'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings'; -import type { CuttingCreateData, CuttingForEdit } from './columns'; +import type { CuttingCreateData, CuttingForEdit, RawMaterialVariant } from './columns'; type MaterialState = { id?: number; @@ -54,13 +54,6 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i)); return cutting.materials.map((m) => { - const rawMaterial = rawMaterials.find((rm) => - rm.raw_material_prices.some((p) => p.id === m.raw_material_price_id), - ); - const price = rawMaterial?.raw_material_prices.find( - (p) => p.id === m.raw_material_price_id, - ); - return { id: m.id, raw_material_price_id: m.raw_material_price_id, @@ -70,11 +63,11 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { m.combination_id !== null ? (comboIdToIndex.get(m.combination_id) ?? null) : null, - variant: price?.variant ?? m.variant ?? '', - material_name: rawMaterial?.name ?? '', - unit: rawMaterial?.unit ?? '', - photo_url: price?.photo_url ?? m.photo_url ?? null, - photo_conversion_url: price?.photo_conversion_url ?? m.photo_conversion_url ?? null, + variant: m.variant ?? '', + material_name: '', + unit: '', + photo_url: m.photo_url ?? null, + photo_conversion_url: null, }; }); }); @@ -89,6 +82,8 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { const [selectedMaterialName, setSelectedMaterialName] = useState(''); const [variantSearch, setVariantSearch] = useState(''); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const [productName, setProductName] = useState(cutting.product_name); const [sample, setSample] = useState(cutting.sample); @@ -127,35 +122,120 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { [rawMaterials, comboMaterialName], ); + useEffect(() => { + const materialIds = cutting.existing_material_ids ?? []; + if (materialIds.length === 0) return; + + const toFetch = materialIds.filter((id) => !fetchedVariants.has(id)); + if (toFetch.length === 0) return; + + setLoadingVariants(true); + Promise.all( + toFetch.map((id) => + fetch(`/admin/master/raw-materials/${id}/variants`) + .then((res) => res.json()) + .then((data) => [id, data.variants] as const) + ), + ) + .then((results) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + for (const [id, variants] of results) { + next.set(id, variants); + } + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [cutting.existing_material_ids]); + const materialsRef = useRef(materials); materialsRef.current = materials; - const priceMap = useMemo( - () => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))), - [rawMaterials], - ); + const priceMap = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const p of variants) { + map.set(p.id, p); + } + } + return map; + }, [fetchedVariants]); const selectedMaterial = useMemo( () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, [rawMaterials, selectedMaterialName], ); + const selectedMaterialVariants = useMemo( + () => (selectedMaterial ? fetchedVariants.get(selectedMaterial.id) ?? [] : []), + [selectedMaterial, fetchedVariants], + ); + + useEffect(() => { + if (!selectedMaterial) return; + if (fetchedVariants.has(selectedMaterial.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/raw-materials/${selectedMaterial.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedMaterial.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedMaterial, fetchedVariants]); + + const comboMaterialVariants = useMemo( + () => (comboMaterial ? fetchedVariants.get(comboMaterial.id) ?? [] : []), + [comboMaterial, fetchedVariants], + ); + + useEffect(() => { + if (!comboMaterial) return; + if (fetchedVariants.has(comboMaterial.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/raw-materials/${comboMaterial.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(comboMaterial.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [comboMaterial, fetchedVariants]); + const groupedVariants = useMemo(() => { if (!variantSearch) return []; + if (!selectedMaterial) return []; const search = variantSearch.toLowerCase(); - return rawMaterials - .map((rm) => ({ - ...rm, - raw_material_prices: rm.raw_material_prices.filter( - (p) => p.variant.toLowerCase().includes(search), - ), - })) - .filter((rm) => rm.raw_material_prices.length > 0); - }, [rawMaterials, variantSearch]); + return [{ + ...selectedMaterial, + raw_material_prices: selectedMaterialVariants.filter( + (p) => p.variant.toLowerCase().includes(search), + ), + }]; + }, [selectedMaterial, selectedMaterialVariants, variantSearch]); const addVariant = useCallback( - (rawMaterial: (typeof rawMaterials)[number], priceId: number) => { - const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId); + (rawMaterial: { id: number; name: string; unit: string }, priceId: number) => { + const variants = fetchedVariants.get(rawMaterial.id) ?? []; + const price = variants.find((p) => p.id === priceId); if (!price) { return; @@ -205,30 +285,22 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { const comboIndex = combinations.length; + const comboMaterialData = rawMaterials.find((m) => m.name === comboMaterialName); + const variants = comboMaterialData ? (fetchedVariants.get(comboMaterialData.id) ?? []) : []; + const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { - let foundMaterial: (typeof rawMaterials)[number] | undefined; - let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined; - - for (const rm of rawMaterials) { - const p = rm.raw_material_prices.find((pp) => pp.id === priceId); - - if (p) { - foundMaterial = rm; - foundPrice = p; - break; - } - } + const price = variants.find((p) => p.id === priceId); return { raw_material_price_id: priceId, material_usage: '0', material_result: 0, combination_id: comboIndex, - variant: foundPrice?.variant ?? '', - material_name: foundMaterial?.name ?? '', - unit: foundMaterial?.unit ?? '', - photo_url: foundPrice?.photo_url ?? null, - photo_conversion_url: foundPrice?.photo_conversion_url ?? null, + variant: price?.variant ?? '', + material_name: comboMaterialData?.name ?? '', + unit: comboMaterialData?.unit ?? '', + photo_url: price?.photo_url ?? null, + photo_conversion_url: price?.photo_conversion_url ?? null, }; }); @@ -244,7 +316,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { setComboMaterialName(''); setComboSelectedPriceIds([]); setComboResult(0); - }, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]); + }, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]); const updateMaterial = useCallback( (index: number, field: keyof MaterialState, value: unknown) => { @@ -368,7 +440,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {

{rm.name} ({rm.unit})

- {rm.raw_material_prices.map((price) => { + {selectedMaterialVariants.filter((p) => p.variant.toLowerCase().includes(variantSearch.toLowerCase())).map((price) => { const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; @@ -411,10 +483,19 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {

Tidak ada varian ditemukan.

)} - {!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && ( + {!variantSearch && selectedMaterial && (
+ {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedMaterialVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : (
- {selectedMaterial.raw_material_prices.map((price) => { + {selectedMaterialVariants.map((price) => { const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; @@ -448,6 +529,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) { ); })}
+ )}
)} @@ -716,20 +798,10 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
{comboSelectedPriceIds.map((priceId) => { - let variantName = ''; - let materialName = ''; - let photoUrl: string | null = null; - - for (const rm of rawMaterials) { - const p = rm.raw_material_prices.find((pp) => pp.id === priceId); - - if (p) { - variantName = p.variant; - materialName = rm.name; - photoUrl = p.photo_conversion_url ?? p.photo_url; - break; - } - } + const p = comboMaterialVariants.find((pp) => pp.id === priceId); + const variantName = p?.variant ?? ''; + const materialName = comboMaterialName; + const photoUrl = p?.photo_conversion_url ?? p?.photo_url ?? null; return (
@@ -754,11 +826,11 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
)} - {comboMaterial && comboMaterial.raw_material_prices.length > 0 && ( + {comboMaterial && comboMaterialVariants.length > 0 && (
- {comboMaterial.raw_material_prices.map((price) => { + {comboMaterialVariants.map((price) => { const isSelected = comboSelectedPriceIds.includes(price.id); return ( diff --git a/resources/js/pages/admin/manage/purchase/columns.tsx b/resources/js/pages/admin/manage/purchase/columns.tsx index 2cac03e..13a627f 100644 --- a/resources/js/pages/admin/manage/purchase/columns.tsx +++ b/resources/js/pages/admin/manage/purchase/columns.tsx @@ -79,6 +79,7 @@ export type PurchaseForEdit = { default_mode: 'new' | 'existing'; existing_material_name: string | null; existing_quantities: Record; + existing_material_ids: number[]; }; export type Supplier = { @@ -92,14 +93,15 @@ export type PurchaseCreateData = { id: number; name: string; unit: string; - is_active: boolean; - raw_material_prices: { - id: number; - variant: string; - price: number; - stock: number; - photo_url: string | null; - photo_conversion_url: string | null; - }[]; }[]; }; + +export type RawMaterialVariant = { + id: number; + raw_material_id: number; + variant: string; + price: number; + stock: number; + photo_url: string | null; + photo_conversion_url: string | null; +}; diff --git a/resources/js/pages/admin/manage/purchase/create.tsx b/resources/js/pages/admin/manage/purchase/create.tsx index 163bdf2..ca07f99 100644 --- a/resources/js/pages/admin/manage/purchase/create.tsx +++ b/resources/js/pages/admin/manage/purchase/create.tsx @@ -11,7 +11,7 @@ import { ShoppingCart, Trash2, } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/dialogs'; import { FileUpload, FileUploadMultiple } from '@/components/inputs'; @@ -48,7 +48,7 @@ import { loadPurchaseDraft } from '@/lib/purchase-draft'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases'; -import type { PurchaseCreateData } from './columns'; +import type { PurchaseCreateData, RawMaterialVariant } from './columns'; type VariantState = { variant: string; @@ -73,7 +73,7 @@ type CartLine = { type Props = { suppliers: PurchaseCreateData['suppliers']; - rawMaterials: PurchaseCreateData['rawMaterials']; + rawMaterials: { id: number; name: string; unit: string }[]; }; export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { @@ -129,6 +129,9 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const [selectedMaterialName, setSelectedMaterialName] = useState( draft?.selectedMaterialName ?? '', ); + + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const [quantities, setQuantities] = useState>(() => Object.fromEntries( Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ @@ -185,28 +188,29 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const variantsRef = useRef(variants); variantsRef.current = variants; - const priceMap = useMemo( - () => - new Map( - rawMaterials.flatMap((m) => - m.raw_material_prices.map((p) => [p.id, p]), - ), - ), - [rawMaterials], - ); + const priceMap = useMemo(() => { + const map = new Map(); + for (const [materialId, variants] of fetchedVariants) { + const material = rawMaterials.find((m) => m.id === materialId); + if (!material) continue; + for (const p of variants) { + map.set(p.id, { ...p, materialName: material.name, materialUnit: material.unit }); + } + } + return map; + }, [fetchedVariants, rawMaterials]); - const materialByPriceId = useMemo( - () => - new Map( - rawMaterials.flatMap((m) => - m.raw_material_prices.map((p) => [ - p.id, - { name: m.name, unit: m.unit }, - ]), - ), - ), - [rawMaterials], - ); + const materialByPriceId = useMemo(() => { + const map = new Map(); + for (const [materialId, variants] of fetchedVariants) { + const material = rawMaterials.find((m) => m.id === materialId); + if (!material) continue; + for (const p of variants) { + map.set(p.id, { name: material.name, unit: material.unit }); + } + } + return map; + }, [fetchedVariants, rawMaterials]); const newSubtotal = variants.reduce( (sum, v) => sum + Number(v.price) * Number(v.stock), @@ -312,6 +316,31 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { [rawMaterials, selectedMaterialName], ); + const selectedMaterialVariants = useMemo( + () => (selectedMaterial ? fetchedVariants.get(selectedMaterial.id) ?? [] : []), + [selectedMaterial, fetchedVariants], + ); + + useEffect(() => { + if (!selectedMaterial || mode !== 'existing') return; + if (fetchedVariants.has(selectedMaterial.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/raw-materials/${selectedMaterial.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedMaterial.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedMaterial, mode, fetchedVariants]); + const updateQuantity = useCallback((priceId: number, value: number) => { setQuantities((prev) => ({ ...prev, @@ -858,7 +887,15 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { {selectedMaterial && (
- {selectedMaterial.raw_material_prices.map( + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedMaterialVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : selectedMaterialVariants.map( (price) => (
(null); const [cartRemoveKey, setCartRemoveKey] = useState(null); - const priceMap = useMemo( - () => - new Map( - rawMaterials.flatMap((m) => - m.raw_material_prices.map((p) => [p.id, p]), - ), - ), - [rawMaterials], - ); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); - const materialByPriceId = useMemo( - () => - new Map( - rawMaterials.flatMap((m) => - m.raw_material_prices.map((p) => [ - p.id, - { name: m.name, unit: m.unit }, - ]), - ), + useEffect(() => { + const materialIds = purchase.existing_material_ids ?? []; + if (materialIds.length === 0) return; + + const toFetch = materialIds.filter((id) => !fetchedVariants.has(id)); + if (toFetch.length === 0) return; + + setLoadingVariants(true); + Promise.all( + toFetch.map((id) => + fetch(`/admin/master/raw-materials/${id}/variants`) + .then((res) => res.json()) + .then((data) => [id, data.variants] as const) ), - [rawMaterials], - ); + ) + .then((results) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + for (const [id, variants] of results) { + next.set(id, variants); + } + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian bahan baku.'); + }) + .finally(() => setLoadingVariants(false)); + }, [purchase.existing_material_ids]); + + const priceMap = useMemo(() => { + const map = new Map(); + for (const [materialId, variants] of fetchedVariants) { + const material = rawMaterials.find((m) => m.id === materialId); + if (!material) continue; + for (const p of variants) { + map.set(p.id, { ...p, materialName: material.name, materialUnit: material.unit }); + } + } + return map; + }, [fetchedVariants, rawMaterials]); + + const materialByPriceId = useMemo(() => { + const map = new Map(); + for (const [materialId, variants] of fetchedVariants) { + const material = rawMaterials.find((m) => m.id === materialId); + if (!material) continue; + for (const p of variants) { + map.set(p.id, { name: material.name, unit: material.unit }); + } + } + return map; + }, [fetchedVariants, rawMaterials]); const subtotal = Object.entries(quantities).reduce( (sum, [priceId, quantity]) => { diff --git a/resources/js/pages/admin/manage/restock/columns.tsx b/resources/js/pages/admin/manage/restock/columns.tsx index 4c6e473..597039e 100644 --- a/resources/js/pages/admin/manage/restock/columns.tsx +++ b/resources/js/pages/admin/manage/restock/columns.tsx @@ -45,6 +45,7 @@ export type RestockForEdit = { notes: string | null; photo_key: string | null; photo_url: string | null; + existing_product_ids: number[]; items: { id: number; product_variant_id: number; @@ -53,19 +54,19 @@ export type RestockForEdit = { }[]; }; +export type ProductVariantForRestock = { + id: number; + name: string; + stock: number; + reject_stock: number; + photo_url: string | null; + capital_price: number; + reject_price: number; +}; + export type ProductForRestock = { id: number; name: string; - status: string; - product_variants: { - id: number; - name: string; - stock: number; - reject_stock: number; - photo_url: string | null; - capital_price: number; - reject_price: number; - }[]; }; export type RestockCreateData = { diff --git a/resources/js/pages/admin/manage/restock/create.tsx b/resources/js/pages/admin/manage/restock/create.tsx index b8c8a94..ead9ff1 100644 --- a/resources/js/pages/admin/manage/restock/create.tsx +++ b/resources/js/pages/admin/manage/restock/create.tsx @@ -35,7 +35,7 @@ import { loadRestockDraft } from '@/lib/restock-draft'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as restockIndex, store } from '@/routes/admin/manage/restocks'; -import type { ProductForRestock, RestockCreateData } from './columns'; +import type { ProductForRestock, ProductVariantForRestock, RestockCreateData } from './columns'; type CartLine = { key: string; @@ -82,6 +82,8 @@ export default function RestockCreate({ products }: Props) { const [cartOpen, setCartOpen] = useState(false); const [previewKey, setPreviewKey] = useState(null); const [cartRemoveKey, setCartRemoveKey] = useState(null); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const draftData = useMemo( () => ({ @@ -112,16 +114,54 @@ export default function RestockCreate({ products }: Props) { [products, selectedProductId], ); - const variantById = useMemo( - () => - new Map( - products.flatMap((p: ProductForRestock) => - p.product_variants.map((v) => [v.id, v]), - ), - ), - [products], + const selectedProductVariants = useMemo( + () => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), + [selectedProduct, fetchedVariants], ); + useEffect(() => { + if (!selectedProduct) return; + if (fetchedVariants.has(selectedProduct.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/products/${selectedProduct.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedProduct.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedProduct, fetchedVariants]); + + const variantById = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const v of variants) { + map.set(v.id, v); + } + } + return map; + }, [fetchedVariants]); + + const productByVariantId = useMemo(() => { + const map = new Map(); + for (const [productId, variants] of fetchedVariants) { + const product = products.find((p) => p.id === productId); + if (product) { + for (const v of variants) { + map.set(v.id, product.name); + } + } + } + return map; + }, [fetchedVariants, products]); + const getUnitPrice = useCallback( (variantId: number) => { const variant = variantById.get(variantId); @@ -294,7 +334,16 @@ return 0; {selectedProduct && (
- {selectedProduct.product_variants.map( + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedProductVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( + selectedProductVariants.map( (variant) => { const currentStock = stockType === 'good' @@ -410,7 +459,7 @@ return 0;
); }, - )} + ))}
)} diff --git a/resources/js/pages/admin/manage/restock/edit.tsx b/resources/js/pages/admin/manage/restock/edit.tsx index d8e3263..ed46ff6 100644 --- a/resources/js/pages/admin/manage/restock/edit.tsx +++ b/resources/js/pages/admin/manage/restock/edit.tsx @@ -25,7 +25,7 @@ import { formatNumber } from '@/lib/format'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as restockIndex, update } from '@/routes/admin/manage/restocks'; -import type { RestockCreateData, RestockForEdit } from './columns'; +import type { RestockCreateData, RestockForEdit, ProductVariantForRestock } from './columns'; type CartLine = { key: string; @@ -49,16 +49,13 @@ export default function RestockEdit({ restock, products }: Props) { const [stockType, setStockType] = useState<'good' | 'reject'>( restock.stock_type === 'reject' ? 'reject' : 'good', ); - const selectedProductId = useMemo(() => { + const [selectedProductId, setSelectedProductId] = useState(() => { const items = restock.items ?? []; const product = products.find((p) => - p.product_variants.some((v) => - items.some((i) => i.product_variant_id === v.id), - ), + restock.existing_product_ids?.includes(p.id), ); - return product ? String(product.id) : ''; - }, [products, restock.items]); + }); const [quantities, setQuantities] = useState>(() => Object.fromEntries( (restock.items ?? []).map((item) => [ @@ -74,6 +71,8 @@ export default function RestockEdit({ restock, products }: Props) { const [cartOpen, setCartOpen] = useState(false); const [previewKey, setPreviewKey] = useState(null); const [cartRemoveKey, setCartRemoveKey] = useState(null); + const [fetchedVariants, setFetchedVariants] = useState>(() => new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const quantitiesRef = useRef(quantities); @@ -86,25 +85,84 @@ export default function RestockEdit({ restock, products }: Props) { [products, selectedProductId], ); - const variantById = useMemo( - () => - new Map( - products.flatMap((p) => - p.product_variants.map((v) => [v.id, v]), - ), - ), - [products], + const selectedProductVariants = useMemo( + () => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), + [selectedProduct, fetchedVariants], ); - const productByVariantId = useMemo( - () => - new Map( - products.flatMap((p) => - p.product_variants.map((v) => [v.id, p.name]), - ), + useEffect(() => { + if (!selectedProduct) return; + if (fetchedVariants.has(selectedProduct.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/products/${selectedProduct.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedProduct.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedProduct, fetchedVariants]); + + useEffect(() => { + if (!restock.existing_product_ids?.length) return; + + const toFetch = restock.existing_product_ids.filter( + (id) => !fetchedVariants.has(id), + ); + if (toFetch.length === 0) return; + + setLoadingVariants(true); + Promise.all( + toFetch.map((id) => + fetch(`/admin/master/products/${id}/variants`) + .then((res) => res.json()) + .then((data) => ({ id, variants: data.variants })), ), - [products], - ); + ) + .then((results) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + for (const { id, variants } of results) { + next.set(id, variants); + } + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [restock.existing_product_ids]); + + const variantById = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const v of variants) { + map.set(v.id, v); + } + } + return map; + }, [fetchedVariants]); + + const productByVariantId = useMemo(() => { + const map = new Map(); + for (const [productId, variants] of fetchedVariants) { + const product = products.find((p) => p.id === productId); + if (product) { + for (const v of variants) { + map.set(v.id, product.name); + } + } + } + return map; + }, [fetchedVariants, products]); const getUnitPrice = useCallback( (variantId: number) => { @@ -232,7 +290,16 @@ return 0; {selectedProduct ? (
- {selectedProduct.product_variants.map( + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedProductVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( + selectedProductVariants.map( (variant) => { const currentStock = stockType === 'good' @@ -348,13 +415,14 @@ return 0;
); }, - )} -
- ) : ( -

- Tidak ada item. -

- )} + ) + )} +
+ ) : ( +

+ Tidak ada item. +

+ )} diff --git a/resources/js/pages/admin/manage/transaction/columns.tsx b/resources/js/pages/admin/manage/transaction/columns.tsx index fb77e5a..f9df1af 100644 --- a/resources/js/pages/admin/manage/transaction/columns.tsx +++ b/resources/js/pages/admin/manage/transaction/columns.tsx @@ -98,21 +98,24 @@ export type TransactionForEdit = { quantity: number; unit_price: number; }[]; + existing_product_ids: number[]; }; export type ProductForTransaction = { id: number; name: string; - status: string; - product_variants: { - id: number; - name: string; - stock: number; - reject_stock: number; - retail_stock: number; - photo_url: string | null; - prices: Record; - }[]; +}; + +export type ProductVariantForTransaction = { + id: number; + product_id: number; + name: string; + stock: number; + reject_stock: number; + retail_stock: number; + photo_url: string | null; + photo_conversion_url: string | null; + prices: Record; }; export type CustomerForTransaction = { diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx index f04171f..8860565 100644 --- a/resources/js/pages/admin/manage/transaction/create.tsx +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -47,7 +47,7 @@ import { loadTransactionDraft } from '@/lib/transaction-draft'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { store, index as transactionIndex } from '@/routes/admin/manage/transactions'; -import type { ProductForTransaction, TransactionCreateData } from './columns'; +import type { ProductForTransaction, ProductVariantForTransaction, TransactionCreateData } from './columns'; type CartLine = { key: string; @@ -119,6 +119,8 @@ export default function TransactionCreate({ const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? ''); const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? ''); const [variantSearch, setVariantSearch] = useState(''); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const quantitiesRef = useRef(quantities); @@ -164,38 +166,64 @@ export default function TransactionCreate({ [products, selectedProductId], ); + const selectedProductVariants = useMemo( + () => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), + [selectedProduct, fetchedVariants], + ); + + useEffect(() => { + if (!selectedProduct) return; + if (fetchedVariants.has(selectedProduct.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/products/${selectedProduct.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedProduct.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedProduct, fetchedVariants]); + const groupedVariants = useMemo(() => { if (!variantSearch) return []; + if (!selectedProduct) return []; const search = variantSearch.toLowerCase(); - return products - .map((p) => ({ - ...p, - product_variants: p.product_variants.filter((v) => - v.name.toLowerCase().includes(search), - ), - })) - .filter((p) => p.product_variants.length > 0); - }, [products, variantSearch]); - - const variantById = useMemo( - () => - new Map( - products.flatMap((p: ProductForTransaction) => - p.product_variants.map((v) => [v.id, v]), - ), + return [{ + ...selectedProduct, + product_variants: selectedProductVariants.filter((v) => + v.name.toLowerCase().includes(search), ), - [products], - ); + }]; + }, [selectedProduct, selectedProductVariants, variantSearch]); - const productByVariantId = useMemo( - () => - new Map( - products.flatMap((p: ProductForTransaction) => - p.product_variants.map((v) => [v.id, p.name]), - ), - ), - [products], - ); + const variantById = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const v of variants) { + map.set(v.id, v); + } + } + return map; + }, [fetchedVariants]); + + const productByVariantId = useMemo(() => { + const map = new Map(); + for (const [productId, variants] of fetchedVariants) { + const product = products.find((p) => p.id === productId); + if (!product) continue; + for (const v of variants) { + map.set(v.id, product.name); + } + } + return map; + }, [fetchedVariants, products]); useEffect(() => { if (channel === 'tiktok') { @@ -508,7 +536,16 @@ export default function TransactionCreate({ {!variantSearch && selectedProduct && (
- {selectedProduct.product_variants.map( + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : selectedProductVariants.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( + selectedProductVariants.map( (variant) => { const currentStock = stockType === 'reject' @@ -528,10 +565,10 @@ export default function TransactionCreate({ } >
- {variant.photo_url ? ( + {variant.photo_conversion_url ?? variant.photo_url ? ( { ); }, - )} -
- )} + ) + )} +
+ )}
diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx index 065dfe4..e12b328 100644 --- a/resources/js/pages/admin/manage/transaction/edit.tsx +++ b/resources/js/pages/admin/manage/transaction/edit.tsx @@ -44,7 +44,7 @@ import { formatNumber } from '@/lib/format'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { index as transactionIndex, update } from '@/routes/admin/manage/transactions'; -import type { TransactionCreateData, TransactionForEdit } from './columns'; +import type { ProductVariantForTransaction, TransactionCreateData, TransactionForEdit } from './columns'; type CartLine = { key: string; @@ -98,15 +98,11 @@ export default function TransactionEdit({ const [photo, setPhoto] = useState(transaction.photo_key); const [photoUrl, setPhotoUrl] = useState(transaction.photo_url); const [uploading, setUploading] = useState(false); + const [fetchedVariants, setFetchedVariants] = useState>(new Map()); + const [loadingVariants, setLoadingVariants] = useState(false); const [selectedProductId, setSelectedProductId] = useState(() => { - const items = transaction.items ?? []; - const product = products.find((p) => - p.product_variants.some((v) => - items.some((i) => i.product_variant_id === v.id), - ), - ); - - return product ? String(product.id) : ''; + const firstProductId = transaction.existing_product_ids?.[0]; + return firstProductId ? String(firstProductId) : ''; }); const [quantities, setQuantities] = useState>(() => Object.fromEntries( @@ -156,25 +152,77 @@ export default function TransactionEdit({ [products, selectedProductId], ); - const variantById = useMemo( - () => - new Map( - products.flatMap((p) => - p.product_variants.map((v) => [v.id, v]), - ), - ), - [products], - ); + useEffect(() => { + const productIds = transaction.existing_product_ids ?? []; + if (productIds.length === 0) return; - const productByVariantId = useMemo( - () => - new Map( - products.flatMap((p) => - p.product_variants.map((v) => [v.id, p.name]), - ), + const toFetch = productIds.filter((id) => !fetchedVariants.has(id)); + if (toFetch.length === 0) return; + + setLoadingVariants(true); + Promise.all( + toFetch.map((id) => + fetch(`/admin/master/products/${id}/variants`) + .then((res) => res.json()) + .then((data) => [id, data.variants] as const) ), - [products], - ); + ) + .then((results) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + for (const [id, variants] of results) { + next.set(id, variants); + } + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [transaction.existing_product_ids]); + + useEffect(() => { + if (!selectedProduct) return; + if (fetchedVariants.has(selectedProduct.id)) return; + + setLoadingVariants(true); + fetch(`/admin/master/products/${selectedProduct.id}/variants`) + .then((res) => res.json()) + .then((data) => { + setFetchedVariants((prev) => { + const next = new Map(prev); + next.set(selectedProduct.id, data.variants); + return next; + }); + }) + .catch(() => { + toast.error('Gagal memuat varian produk.'); + }) + .finally(() => setLoadingVariants(false)); + }, [selectedProduct, fetchedVariants]); + + const variantById = useMemo(() => { + const map = new Map(); + for (const [, variants] of fetchedVariants) { + for (const v of variants) { + map.set(v.id, v); + } + } + return map; + }, [fetchedVariants]); + + const productByVariantId = useMemo(() => { + const map = new Map(); + for (const [productId, variants] of fetchedVariants) { + const product = products.find((p) => p.id === productId); + if (!product) continue; + for (const v of variants) { + map.set(v.id, product.name); + } + } + return map; + }, [fetchedVariants, products]); const showPhoto = paymentType === 'transfer' || paymentType === 'qris'; @@ -397,7 +445,16 @@ export default function TransactionEdit({ {selectedProduct && (
- {selectedProduct.product_variants.map( + {loadingVariants ? ( +
+ Memuat varian... +
+ ) : fetchedVariants.get(selectedProduct.id)?.length === 0 ? ( +
+ Tidak ada varian ditemukan. +
+ ) : ( + (fetchedVariants.get(selectedProduct.id) ?? []).map( (variant) => { const currentStock = stockType === 'reject' @@ -518,9 +575,10 @@ export default function TransactionEdit({
); }, - )} -
- )} + ) + )} +
+ )}
diff --git a/routes/web.php b/routes/web.php index 4f039a1..a774606 100644 --- a/routes/web.php +++ b/routes/web.php @@ -44,6 +44,7 @@ Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:categories.view|categories.create|categories.update|categories.delete'); Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete'); + Route::get('products-active', [ProductController::class, 'activeProducts'])->name('products.active')->middleware('permission:products.view'); Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status')->middleware('permission:products.toggle_status'); Route::post('products/{product}/toggle-featured', [ProductController::class, 'toggleFeatured'])->name('products.toggle-featured')->middleware('permission:products.toggle_featured'); Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update'); @@ -57,6 +58,7 @@ Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations')->middleware('permission:products.view_stock_mutations'); Route::resource('raw-materials', RawMaterialController::class)->except(['show'])->middleware('permission:raw_materials.view|raw_materials.create|raw_materials.update|raw_materials.delete'); + Route::get('raw-materials-active', [RawMaterialController::class, 'activeMaterials'])->name('raw-materials.active')->middleware('permission:raw_materials.view'); Route::post('raw-materials/{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])->name('raw-materials.toggle-status')->middleware('permission:raw_materials.toggle_status'); Route::get('raw-materials/{rawMaterial}/variants', [RawMaterialController::class, 'variants'])->name('raw-materials.variants')->middleware('permission:raw_materials.view'); Route::get('raw-materials/{rawMaterial}/variants/{variant}/edit', [RawMaterialVariantController::class, 'edit'])->name('raw-materials.variants.edit')->middleware('permission:raw_materials.update');