Compare commits

..

No commits in common. "f128ace2ea746f073aba12f527c3772805f04157" and "9fead3dfa94fc25c03d8c697af64ccb91cf76f9e" have entirely different histories.

30 changed files with 449 additions and 1013 deletions

View File

@ -8,7 +8,6 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
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\RawMaterialService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService; use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -19,7 +18,6 @@ class CuttingController extends Controller
{ {
public function __construct( public function __construct(
private CuttingService $service, private CuttingService $service,
private RawMaterialService $rawMaterialService,
private RawMaterialVariantService $rawMaterialVariantService, private RawMaterialVariantService $rawMaterialVariantService,
) {} ) {}
@ -42,7 +40,7 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/cutting/create', [ return Inertia::render('admin/manage/cutting/create', [
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
@ -74,7 +72,7 @@ public function edit(Cutting $cutting): Response
{ {
return Inertia::render('admin/manage/cutting/edit', [ return Inertia::render('admin/manage/cutting/edit', [
'cutting' => $this->service->getForEdit($cutting), 'cutting' => $this->service->getForEdit($cutting),
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }

View File

@ -7,7 +7,6 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Purchase; use App\Models\Purchase;
use App\Services\Admin\Manage\PurchaseService; 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\RawMaterial\RawMaterialVariantService;
use App\Services\Admin\Master\SupplierService; use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -20,7 +19,6 @@ class PurchaseController extends Controller
public function __construct( public function __construct(
private readonly PurchaseService $service, private readonly PurchaseService $service,
private readonly SupplierService $supplierService, private readonly SupplierService $supplierService,
private readonly RawMaterialService $rawMaterialService,
private readonly RawMaterialVariantService $rawMaterialVariantService, private readonly RawMaterialVariantService $rawMaterialVariantService,
) {} ) {}
@ -41,7 +39,7 @@ public function create(): Response
{ {
return Inertia::render('admin/manage/purchase/create', [ return Inertia::render('admin/manage/purchase/create', [
'suppliers' => $this->supplierService->getAll(), 'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
@ -60,7 +58,7 @@ public function edit(Purchase $purchase): Response
return Inertia::render('admin/manage/purchase/edit', [ return Inertia::render('admin/manage/purchase/edit', [
'purchase' => $this->service->getForEdit($purchase), 'purchase' => $this->service->getForEdit($purchase),
'suppliers' => $this->supplierService->getAll(), 'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }

View File

@ -7,7 +7,6 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Restock; use App\Models\Restock;
use App\Services\Admin\Manage\RestockService; use App\Services\Admin\Manage\RestockService;
use App\Services\Admin\Master\Product\ProductService;
use App\Services\Admin\Master\Product\ProductVariantService; use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -18,7 +17,6 @@ class RestockController extends Controller
{ {
public function __construct( public function __construct(
private RestockService $service, private RestockService $service,
private ProductService $productService,
private ProductVariantService $productVariantService, private ProductVariantService $productVariantService,
) {} ) {}
@ -35,7 +33,7 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/restock/create', [ return Inertia::render('admin/manage/restock/create', [
'products' => $this->productService->getActiveProducts(), 'products' => $this->productVariantService->getForRestock(),
]); ]);
} }
@ -53,7 +51,7 @@ public function edit(Restock $restock): Response
{ {
return Inertia::render('admin/manage/restock/edit', [ return Inertia::render('admin/manage/restock/edit', [
'restock' => $this->service->getForEdit($restock), 'restock' => $this->service->getForEdit($restock),
'products' => $this->productService->getActiveProducts(), 'products' => $this->productVariantService->getForRestock(),
]); ]);
} }

View File

@ -13,7 +13,6 @@
use App\Models\User; use App\Models\User;
use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService; use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductService;
use App\Services\Admin\Master\Product\ProductVariantService; use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -24,7 +23,6 @@ class TransactionController extends Controller
{ {
public function __construct( public function __construct(
private TransactionService $service, private TransactionService $service,
private ProductService $productService,
private ProductVariantService $productVariantService, private ProductVariantService $productVariantService,
private CustomerService $customerService, private CustomerService $customerService,
) {} ) {}
@ -52,7 +50,7 @@ public function create(): Response
$isCashier = $user->hasRole(Role::CASHIER); $isCashier = $user->hasRole(Role::CASHIER);
return Inertia::render('admin/manage/transaction/create', [ return Inertia::render('admin/manage/transaction/create', [
'products' => $this->productService->getActiveProducts(), 'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(), 'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(), 'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(), 'channelOptions' => OrderChannel::toSelect(),
@ -82,7 +80,7 @@ public function edit(Order $transaction): Response
return Inertia::render('admin/manage/transaction/edit', [ return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction), 'transaction' => $this->service->getForEdit($transaction),
'products' => $this->productService->getActiveProducts(), 'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(), 'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(), 'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(), 'channelOptions' => OrderChannel::toSelect(),

View File

@ -133,11 +133,4 @@ public function variants(Product $product): JsonResponse
'variants' => $this->service->getVariants($product), 'variants' => $this->service->getVariants($product),
]); ]);
} }
public function activeProducts(): JsonResponse
{
return response()->json([
'products' => $this->service->getActiveProducts(),
]);
}
} }

View File

@ -92,11 +92,4 @@ public function variants(RawMaterial $rawMaterial): JsonResponse
'variants' => $this->service->getVariants($rawMaterial), 'variants' => $this->service->getVariants($rawMaterial),
]); ]);
} }
public function activeMaterials(): JsonResponse
{
return response()->json([
'materials' => $this->service->getActiveMaterials(),
]);
}
} }

View File

@ -31,11 +31,11 @@ public function rules(): array
'variants' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'], 'variants' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'variants.*.variant' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'], 'variants.*.variant' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'variants.*.price' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'], 'variants.*.price' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'numeric', 'min:0'], 'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.photo_key' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:500'], 'variants.*.photo_key' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:500'],
'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'], 'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')], 'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'existing_items.*.quantity' => ['required', 'numeric', 'min:0.01'], 'existing_items.*.quantity' => ['required', 'integer', 'min:1'],
'existing_items.*.unit_price' => ['required', 'integer', 'min:0'], 'existing_items.*.unit_price' => ['required', 'integer', 'min:0'],
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')], 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')],
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],

View File

@ -19,9 +19,9 @@ class PurchaseItem extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'quantity' => 'decimal:2', 'quantity' => 'integer',
'unit_price' => 'integer', 'unit_price' => 'integer',
'subtotal' => 'decimal:2', 'subtotal' => 'integer',
]; ];
} }

View File

@ -257,12 +257,6 @@ public function getForEdit(Cutting $cutting): array
'combinations' => $combinations, 'combinations' => $combinations,
'photo_keys' => $photoKeys, 'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls, 'photo_urls' => $photoUrls,
'existing_material_ids' => $cutting->cuttingMaterials
->map(fn (CuttingMaterial $m) => $m->rawMaterialPrice?->raw_material_id)
->filter()
->unique()
->values()
->all(),
]; ];
} }

View File

@ -151,13 +151,11 @@ public function getForEdit(Purchase $purchase): array
fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name
)->toArray(); )->toArray();
$purchasePhotoUrls = $purchaseMedia->map( $purchasePhotoUrls = $purchaseMedia->map(
fn ($media) => $media->getGeneratedConversions()->contains('thumb') fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray(); )->toArray();
$existingQuantities = $items->mapWithKeys(fn (PurchaseItem $item) => [ $existingQuantities = $items->mapWithKeys(fn (PurchaseItem $item) => [
(int) $item->raw_material_price_id => (string) $item->quantity, (int) $item->raw_material_price_id => (int) $item->quantity,
])->all(); ])->all();
return [ return [
@ -174,7 +172,6 @@ public function getForEdit(Purchase $purchase): array
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing', 'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
'existing_material_name' => $materials->first()->name ?? null, 'existing_material_name' => $materials->first()->name ?? null,
'existing_quantities' => $existingQuantities, 'existing_quantities' => $existingQuantities,
'existing_material_ids' => $materials->pluck('id')->values()->all(),
]; ];
} }
@ -194,7 +191,7 @@ private function storeFromExisting(array $data): Purchase
$subtotal = 0; $subtotal = 0;
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
$itemSubtotal = $item['unit_price'] * $item['quantity']; $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
return [ return [
@ -267,7 +264,7 @@ private function storeNew(array $data): Purchase
$subtotal = 0; $subtotal = 0;
$now = now(); $now = now();
$priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) { $priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) {
$itemSubtotal = $v['price'] * $v['stock']; $itemSubtotal = (int) ($v['price'] * $v['stock']);
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
return [ return [
@ -325,7 +322,7 @@ private function storeNew(array $data): Purchase
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'quantity' => $v['stock'], 'quantity' => $v['stock'],
'unit_price' => $v['price'], 'unit_price' => $v['price'],
'subtotal' => $v['price'] * $v['stock'], 'subtotal' => (int) ($v['price'] * $v['stock']),
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]; ];
@ -378,7 +375,7 @@ public function update(Purchase $purchase, array $data): Purchase
if (($data['mode'] ?? 'new') === 'existing') { if (($data['mode'] ?? 'new') === 'existing') {
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
$itemSubtotal = $item['unit_price'] * $item['quantity']; $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
return [ return [
@ -429,7 +426,7 @@ public function update(Purchase $purchase, array $data): Purchase
} }
if ($price) { if ($price) {
$price->increment('stock', $v['stock']); $price->increment('stock', (int) $v['stock']);
$price->update(['price' => $v['price']]); $price->update(['price' => $v['price']]);
} else { } else {
$price = $rawMaterial->rawMaterialPrices()->create([ $price = $rawMaterial->rawMaterialPrices()->create([
@ -449,7 +446,7 @@ public function update(Purchase $purchase, array $data): Purchase
); );
} }
$itemSubtotal = $v['price'] * $v['stock']; $itemSubtotal = (int) ($v['price'] * $v['stock']);
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
return [ return [

View File

@ -66,7 +66,7 @@ public function getForEdit(Restock $restock): array
$restock->load([ $restock->load([
'restockItems' => fn ($q) => $q 'restockItems' => fn ($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']), ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']),
'restockItems.productVariant:id,product_id,name', 'restockItems.productVariant:id,name',
]); ]);
$media = $restock->getFirstMedia('photos'); $media = $restock->getFirstMedia('photos');
@ -83,12 +83,6 @@ public function getForEdit(Restock $restock): array
'quantity' => $item->quantity, 'quantity' => $item->quantity,
'unit_price' => $item->unit_price, 'unit_price' => $item->unit_price,
]), ]),
'existing_product_ids' => $restock->restockItems
->map(fn ($item) => $item->productVariant?->product_id)
->filter()
->unique()
->values()
->all(),
]; ];
} }

View File

@ -107,7 +107,6 @@ public function getForEdit(Order $order): array
{ {
$order->load([ $order->load([
'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price', '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'; $stockType = $order->orderItems->first()?->stock_quality?->value ?? 'good';
@ -139,12 +138,6 @@ public function getForEdit(Order $order): array
'quantity' => $item->quantity, 'quantity' => $item->quantity,
'unit_price' => $item->unit_price, 'unit_price' => $item->unit_price,
]), ]),
'existing_product_ids' => $order->orderItems
->map(fn (OrderItem $item) => $item->productVariant?->product_id)
->filter()
->unique()
->values()
->all(),
]; ];
} }

View File

@ -66,14 +66,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function getActiveProducts(): \Illuminate\Support\Collection
{
return Product::select(['id', 'name'])
->active()
->orderBy('name')
->get();
}
public function getVariants(Product $product): Collection public function getVariants(Product $product): Collection
{ {
return $product->productVariants() return $product->productVariants()

View File

@ -60,36 +60,6 @@ public function getForStokOpname(): array
})->toArray(); })->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 public function getForRestock(): array
{ {
$products = Product::query() $products = Product::query()

View File

@ -57,14 +57,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function getActiveMaterials(): Collection
{
return RawMaterial::select(['id', 'name', 'unit'])
->active()
->orderBy('name')
->get();
}
public function getVariants(RawMaterial $rawMaterial): Collection public function getVariants(RawMaterial $rawMaterial): Collection
{ {
return $rawMaterial->rawMaterialPrices() return $rawMaterial->rawMaterialPrices()

View File

@ -1,30 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('purchase_items', function (Blueprint $table) {
$table->decimal('quantity', 10, 2)->default(0)->change();
$table->decimal('subtotal', 14, 2)->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('purchase_items', function (Blueprint $table) {
$table->unsignedInteger('quantity')->default(0)->change();
$table->unsignedInteger('subtotal')->default(0)->change();
});
}
};

View File

@ -10,12 +10,12 @@ export type PurchaseDraftData = {
variants: Array<{ variants: Array<{
variant: string; variant: string;
price: number; price: number;
stock: string; stock: number;
photo_keys?: string[]; photo_keys?: string[];
}>; }>;
mode?: 'new' | 'existing'; mode?: 'new' | 'existing';
selectedMaterialName?: string; selectedMaterialName?: string;
quantities?: Record<string, string>; quantities?: Record<string, number>;
photo?: string; photo?: string;
}; };

View File

@ -91,7 +91,6 @@ export type CuttingForEdit = {
}[]; }[];
photo_keys: string[]; photo_keys: string[];
photo_urls: string[]; photo_urls: string[];
existing_material_ids: number[];
}; };
export type CuttingCreateData = { export type CuttingCreateData = {
@ -99,15 +98,14 @@ export type CuttingCreateData = {
id: number; id: number;
name: string; name: string;
unit: 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;
};

View File

@ -2,7 +2,7 @@
import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUploadMultiple } from '@/components/inputs'; import { FileUploadMultiple } from '@/components/inputs';
@ -23,7 +23,7 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings'; import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
import type { CuttingCreateData, RawMaterialVariant } from './columns'; import type { CuttingCreateData } from './columns';
type MaterialState = { type MaterialState = {
raw_material_price_id: number; raw_material_price_id: number;
@ -79,8 +79,6 @@ export default function CuttingCreate({ rawMaterials }: Props) {
}); });
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? ''); const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
const [variantSearch, setVariantSearch] = useState(''); const [variantSearch, setVariantSearch] = useState('');
const [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const [productName, setProductName] = useState(draft?.productName ?? ''); const [productName, setProductName] = useState(draft?.productName ?? '');
const [sample, setSample] = useState(draft?.sample ?? 0); const [sample, setSample] = useState(draft?.sample ?? 0);
@ -150,87 +148,32 @@ export default function CuttingCreate({ rawMaterials }: Props) {
const materialsRef = useRef(materials); const materialsRef = useRef(materials);
materialsRef.current = materials; materialsRef.current = materials;
const priceMap = useMemo(() => { const priceMap = useMemo(
const map = new Map<number, RawMaterialVariant>(); () => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
for (const [, variants] of fetchedVariants) { [rawMaterials],
for (const p of variants) { );
map.set(p.id, p);
}
}
return map;
}, [fetchedVariants]);
const selectedMaterial = useMemo( const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName], [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(() => { const groupedVariants = useMemo(() => {
if (!variantSearch) return []; if (!variantSearch) return [];
if (!selectedMaterial) return [];
const search = variantSearch.toLowerCase(); const search = variantSearch.toLowerCase();
return [{ return rawMaterials
...selectedMaterial, .map((rm) => ({
raw_material_prices: selectedMaterialVariants.filter( ...rm,
(p) => p.variant.toLowerCase().includes(search), raw_material_prices: rm.raw_material_prices.filter(
), (p) => p.variant.toLowerCase().includes(search),
}]; ),
}, [selectedMaterial, selectedMaterialVariants, variantSearch]); }))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const addVariant = useCallback( const addVariant = useCallback(
(rawMaterial: { id: number; name: string; unit: string }, priceId: number) => { (rawMaterial: (typeof rawMaterials)[number], priceId: number) => {
const variants = fetchedVariants.get(rawMaterial.id) ?? []; const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
const price = variants.find((p) => p.id === priceId);
if (!price) { if (!price) {
return; return;
@ -257,7 +200,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
]; ];
}); });
}, },
[fetchedVariants], [],
); );
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => { const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
@ -280,22 +223,30 @@ export default function CuttingCreate({ rawMaterials }: Props) {
const comboIndex = combinations.length; 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) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
const price = variants.find((p) => p.id === 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;
}
}
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: '0', material_usage: '0',
material_result: 0, material_result: 0,
combination_id: comboIndex, combination_id: comboIndex,
variant: price?.variant ?? '', variant: foundPrice?.variant ?? '',
material_name: comboMaterialData?.name ?? '', material_name: foundMaterial?.name ?? '',
unit: comboMaterialData?.unit ?? '', unit: foundMaterial?.unit ?? '',
photo_url: price?.photo_url ?? null, photo_url: foundPrice?.photo_url ?? null,
photo_conversion_url: price?.photo_conversion_url ?? null, photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
}; };
}); });
@ -311,7 +262,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
setComboMaterialName(''); setComboMaterialName('');
setComboSelectedPriceIds([]); setComboSelectedPriceIds([]);
setComboResult(0); setComboResult(0);
}, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]); }, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
const removeMaterial = useCallback((index: number) => { const removeMaterial = useCallback((index: number) => {
setDeleteMaterialIndex(index); setDeleteMaterialIndex(index);
@ -483,53 +434,43 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p> <p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)} )}
{!variantSearch && selectedMaterial && ( {!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( <div className="space-y-2">
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> {selectedMaterial.raw_material_prices.map((price) => {
Memuat varian... const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
</div> const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
) : selectedMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
<div className="space-y-2">
{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 ( return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}> <div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{price.photo_conversion_url ?? price.photo_url ? ( {price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" /> <img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : ( ) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p> <p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)} Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`} {addedCount > 0 && ` · ×${addedCount}`}
</p> </p>
</div>
</div>
<div className="flex items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div> </div>
</div> </div>
); <div className="flex items-center gap-1">
})} <Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}>
</div> <Plus className="h-4 w-4" />
)} Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -799,13 +740,18 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<div className="space-y-1"> <div className="space-y-1">
{comboSelectedPriceIds.map((priceId) => { {comboSelectedPriceIds.map((priceId) => {
let variantName = ''; let variantName = '';
let materialName = comboMaterialName; let materialName = '';
let photoUrl: string | null = null; let photoUrl: string | null = null;
const price = comboMaterialVariants.find((pp) => pp.id === priceId); for (const rm of rawMaterials) {
if (price) { const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
variantName = price.variant;
photoUrl = price.photo_conversion_url ?? price.photo_url; if (p) {
variantName = p.variant;
materialName = rm.name;
photoUrl = p.photo_conversion_url ?? p.photo_url;
break;
}
} }
return ( return (
@ -831,43 +777,33 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</div> </div>
)} )}
{comboMaterial && ( {comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label> <Label className="text-sm font-medium">Pilih Varian</Label>
{loadingVariants ? ( <div className="space-y-2">
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground"> {comboMaterial.raw_material_prices.map((price) => {
Memuat varian... const isSelected = comboSelectedPriceIds.includes(price.id);
</div>
) : comboMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
<div className="space-y-2">
{comboMaterialVariants.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}> <div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{price.photo_conversion_url ?? price.photo_url ? ( {price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" /> <img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : ( ) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p> <p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p> <p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
</div>
</div> </div>
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
</Button>
</div> </div>
); <Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
})} {isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
</div> </Button>
)} </div>
);
})}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -2,7 +2,7 @@
import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUploadMultiple } from '@/components/inputs'; import { FileUploadMultiple } from '@/components/inputs';
@ -21,7 +21,7 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings'; import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
import type { CuttingCreateData, CuttingForEdit, RawMaterialVariant } from './columns'; import type { CuttingCreateData, CuttingForEdit } from './columns';
type MaterialState = { type MaterialState = {
id?: number; id?: number;
@ -54,6 +54,13 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i)); cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i));
return cutting.materials.map((m) => { 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 { return {
id: m.id, id: m.id,
raw_material_price_id: m.raw_material_price_id, raw_material_price_id: m.raw_material_price_id,
@ -63,11 +70,11 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
m.combination_id !== null m.combination_id !== null
? (comboIdToIndex.get(m.combination_id) ?? null) ? (comboIdToIndex.get(m.combination_id) ?? null)
: null, : null,
variant: m.variant ?? '', variant: price?.variant ?? m.variant ?? '',
material_name: '', material_name: rawMaterial?.name ?? '',
unit: '', unit: rawMaterial?.unit ?? '',
photo_url: m.photo_url ?? null, photo_url: price?.photo_url ?? m.photo_url ?? null,
photo_conversion_url: null, photo_conversion_url: price?.photo_conversion_url ?? m.photo_conversion_url ?? null,
}; };
}); });
}); });
@ -82,8 +89,6 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
const [selectedMaterialName, setSelectedMaterialName] = useState(''); const [selectedMaterialName, setSelectedMaterialName] = useState('');
const [variantSearch, setVariantSearch] = useState(''); const [variantSearch, setVariantSearch] = useState('');
const [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const [productName, setProductName] = useState(cutting.product_name); const [productName, setProductName] = useState(cutting.product_name);
const [sample, setSample] = useState(cutting.sample); const [sample, setSample] = useState(cutting.sample);
@ -122,120 +127,35 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
[rawMaterials, comboMaterialName], [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); const materialsRef = useRef(materials);
materialsRef.current = materials; materialsRef.current = materials;
const priceMap = useMemo(() => { const priceMap = useMemo(
const map = new Map<number, RawMaterialVariant>(); () => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
for (const [, variants] of fetchedVariants) { [rawMaterials],
for (const p of variants) { );
map.set(p.id, p);
}
}
return map;
}, [fetchedVariants]);
const selectedMaterial = useMemo( const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName], [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(() => { const groupedVariants = useMemo(() => {
if (!variantSearch) return []; if (!variantSearch) return [];
if (!selectedMaterial) return [];
const search = variantSearch.toLowerCase(); const search = variantSearch.toLowerCase();
return [{ return rawMaterials
...selectedMaterial, .map((rm) => ({
raw_material_prices: selectedMaterialVariants.filter( ...rm,
(p) => p.variant.toLowerCase().includes(search), raw_material_prices: rm.raw_material_prices.filter(
), (p) => p.variant.toLowerCase().includes(search),
}]; ),
}, [selectedMaterial, selectedMaterialVariants, variantSearch]); }))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const addVariant = useCallback( const addVariant = useCallback(
(rawMaterial: { id: number; name: string; unit: string }, priceId: number) => { (rawMaterial: (typeof rawMaterials)[number], priceId: number) => {
const variants = fetchedVariants.get(rawMaterial.id) ?? []; const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
const price = variants.find((p) => p.id === priceId);
if (!price) { if (!price) {
return; return;
@ -285,22 +205,30 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
const comboIndex = combinations.length; 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) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
const price = variants.find((p) => p.id === 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;
}
}
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: '0', material_usage: '0',
material_result: 0, material_result: 0,
combination_id: comboIndex, combination_id: comboIndex,
variant: price?.variant ?? '', variant: foundPrice?.variant ?? '',
material_name: comboMaterialData?.name ?? '', material_name: foundMaterial?.name ?? '',
unit: comboMaterialData?.unit ?? '', unit: foundMaterial?.unit ?? '',
photo_url: price?.photo_url ?? null, photo_url: foundPrice?.photo_url ?? null,
photo_conversion_url: price?.photo_conversion_url ?? null, photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
}; };
}); });
@ -316,7 +244,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
setComboMaterialName(''); setComboMaterialName('');
setComboSelectedPriceIds([]); setComboSelectedPriceIds([]);
setComboResult(0); setComboResult(0);
}, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]); }, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
const updateMaterial = useCallback( const updateMaterial = useCallback(
(index: number, field: keyof MaterialState, value: unknown) => { (index: number, field: keyof MaterialState, value: unknown) => {
@ -440,7 +368,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<div key={rm.id} className="space-y-2"> <div key={rm.id} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p> <p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
<div className="space-y-2"> <div className="space-y-2">
{selectedMaterialVariants.filter((p) => p.variant.toLowerCase().includes(variantSearch.toLowerCase())).map((price) => { {rm.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); 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; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
@ -483,19 +411,10 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p> <p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)} )}
{!variantSearch && selectedMaterial && ( {!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
<div className="space-y-2"> <div className="space-y-2">
{selectedMaterialVariants.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); 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; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
@ -529,7 +448,6 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
); );
})} })}
</div> </div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@ -798,10 +716,20 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<Label className="text-sm font-medium">Varian Dipilih</Label> <Label className="text-sm font-medium">Varian Dipilih</Label>
<div className="space-y-1"> <div className="space-y-1">
{comboSelectedPriceIds.map((priceId) => { {comboSelectedPriceIds.map((priceId) => {
const p = comboMaterialVariants.find((pp) => pp.id === priceId); let variantName = '';
const variantName = p?.variant ?? ''; let materialName = '';
const materialName = comboMaterialName; let photoUrl: string | null = null;
const photoUrl = p?.photo_conversion_url ?? p?.photo_url ?? 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;
}
}
return ( return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2"> <div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
@ -826,11 +754,11 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</div> </div>
)} )}
{comboMaterial && comboMaterialVariants.length > 0 && ( {comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label> <Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2"> <div className="space-y-2">
{comboMaterialVariants.map((price) => { {comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id); const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (

View File

@ -79,7 +79,6 @@ export type PurchaseForEdit = {
default_mode: 'new' | 'existing'; default_mode: 'new' | 'existing';
existing_material_name: string | null; existing_material_name: string | null;
existing_quantities: Record<string, number>; existing_quantities: Record<string, number>;
existing_material_ids: number[];
}; };
export type Supplier = { export type Supplier = {
@ -93,15 +92,14 @@ export type PurchaseCreateData = {
id: number; id: number;
name: string; name: string;
unit: 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;
};

View File

@ -11,12 +11,13 @@ import {
ShoppingCart, ShoppingCart,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload, FileUploadMultiple } from '@/components/inputs'; import { FileUpload, FileUploadMultiple } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs'; import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -47,12 +48,12 @@ import { loadPurchaseDraft } from '@/lib/purchase-draft';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases'; import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases';
import type { PurchaseCreateData, RawMaterialVariant } from './columns'; import type { PurchaseCreateData } from './columns';
type VariantState = { type VariantState = {
variant: string; variant: string;
price: number; price: number;
stock: string; stock: number;
photo: string | null; photo: string | null;
photoUrl: string | null; photoUrl: string | null;
uploading: boolean; uploading: boolean;
@ -64,15 +65,15 @@ type CartLine = {
title: string; title: string;
subtitle: string; subtitle: string;
price: number; price: number;
quantity: string; quantity: number;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSet: (value: string) => void; onSet: (value: number) => void;
onRemove: () => void; onRemove: () => void;
}; };
type Props = { type Props = {
suppliers: PurchaseCreateData['suppliers']; suppliers: PurchaseCreateData['suppliers'];
rawMaterials: { id: number; name: string; unit: string }[]; rawMaterials: PurchaseCreateData['rawMaterials'];
}; };
export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
@ -88,7 +89,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
return draft.variants.map((v) => ({ return draft.variants.map((v) => ({
variant: v.variant, variant: v.variant,
price: v.price, price: v.price,
stock: String(v.stock), stock: v.stock,
photo: v.photo ?? null, photo: v.photo ?? null,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null, photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
uploading: false, uploading: false,
@ -99,7 +100,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
{ {
variant: '', variant: '',
price: 0, price: 0,
stock: '0', stock: 0,
photo: null, photo: null,
photoUrl: null, photoUrl: null,
uploading: false, uploading: false,
@ -128,14 +129,11 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const [selectedMaterialName, setSelectedMaterialName] = useState( const [selectedMaterialName, setSelectedMaterialName] = useState(
draft?.selectedMaterialName ?? '', draft?.selectedMaterialName ?? '',
); );
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
const [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const [quantities, setQuantities] = useState<Record<number, string>>(() =>
Object.fromEntries( Object.fromEntries(
Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [
Number(id), Number(id),
String(qty), qty,
]), ]),
), ),
); );
@ -187,39 +185,38 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const variantsRef = useRef(variants); const variantsRef = useRef(variants);
variantsRef.current = variants; variantsRef.current = variants;
const priceMap = useMemo(() => { const priceMap = useMemo(
const map = new Map<number, RawMaterialVariant & { materialName: string; materialUnit: string }>(); () =>
for (const [materialId, variants] of fetchedVariants) { new Map(
const material = rawMaterials.find((m) => m.id === materialId); rawMaterials.flatMap((m) =>
if (!material) continue; m.raw_material_prices.map((p) => [p.id, p]),
for (const p of variants) { ),
map.set(p.id, { ...p, materialName: material.name, materialUnit: material.unit }); ),
} [rawMaterials],
} );
return map;
}, [fetchedVariants, rawMaterials]);
const materialByPriceId = useMemo(() => { const materialByPriceId = useMemo(
const map = new Map<number, { name: string; unit: string }>(); () =>
for (const [materialId, variants] of fetchedVariants) { new Map(
const material = rawMaterials.find((m) => m.id === materialId); rawMaterials.flatMap((m) =>
if (!material) continue; m.raw_material_prices.map((p) => [
for (const p of variants) { p.id,
map.set(p.id, { name: material.name, unit: material.unit }); { name: m.name, unit: m.unit },
} ]),
} ),
return map; ),
}, [fetchedVariants, rawMaterials]); [rawMaterials],
);
const newSubtotal = variants.reduce( const newSubtotal = variants.reduce(
(sum, v) => sum + Number(v.price) * (parseFloat(v.stock) || 0), (sum, v) => sum + Number(v.price) * Number(v.stock),
0, 0,
); );
const existingSubtotal = Object.entries(quantities).reduce( const existingSubtotal = Object.entries(quantities).reduce(
(sum, [priceId, quantity]) => { (sum, [priceId, quantity]) => {
const price = priceMap.get(Number(priceId)); const price = priceMap.get(Number(priceId));
return sum + (price ? price.price * (parseFloat(quantity) || 0) : 0); return sum + (price ? price.price * quantity : 0);
}, },
0, 0,
); );
@ -232,7 +229,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
{ {
variant: '', variant: '',
price: 0, price: 0,
stock: '0', stock: 0,
photo: null, photo: null,
photoUrl: null, photoUrl: null,
uploading: false, uploading: false,
@ -315,42 +312,17 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
[rawMaterials, selectedMaterialName], [rawMaterials, selectedMaterialName],
); );
const selectedMaterialVariants = useMemo( const updateQuantity = useCallback((priceId: number, value: number) => {
() => (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: string) => {
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[priceId]: value, [priceId]: Math.max(0, value),
})); }));
}, []); }, []);
const incrementQuantity = useCallback((priceId: number, amount: number) => { const incrementQuantity = useCallback((priceId: number, amount: number) => {
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[priceId]: String(Math.max(0, (parseFloat(prev[priceId]) || 0) + amount)), [priceId]: Math.max(0, (prev[priceId] ?? 0) + amount),
})); }));
}, []); }, []);
@ -359,7 +331,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const updated = [...prev]; const updated = [...prev];
updated[index] = { updated[index] = {
...updated[index], ...updated[index],
stock: String(Math.max(0, (parseFloat(updated[index].stock) || 0) + amount)), stock: Math.max(0, Number(updated[index].stock) + amount),
}; };
return updated; return updated;
@ -371,7 +343,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const lines: CartLine[] = []; const lines: CartLine[] = [];
for (const [priceId, quantity] of Object.entries(quantities)) { for (const [priceId, quantity] of Object.entries(quantities)) {
if ((parseFloat(quantity) || 0) <= 0) { if (quantity <= 0) {
continue; continue;
} }
@ -389,7 +361,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
quantity, quantity,
onAdjust: (delta) => incrementQuantity(id, delta), onAdjust: (delta) => incrementQuantity(id, delta),
onSet: (value) => updateQuantity(id, value), onSet: (value) => updateQuantity(id, value),
onRemove: () => updateQuantity(id, '0'), onRemove: () => updateQuantity(id, 0),
}); });
} }
} }
@ -409,7 +381,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
onSet: (value) => updateVariant(index, 'stock', value), onSet: (value) => updateVariant(index, 'stock', value),
onRemove: () => removeVariant(index), onRemove: () => removeVariant(index),
})) }))
.filter((line) => (parseFloat(line.quantity) || 0) > 0); .filter((line) => line.quantity > 0);
})(); })();
function formatQuantity(value: number): string { function formatQuantity(value: number): string {
@ -432,7 +404,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
existing_items: Object.entries(quantities) existing_items: Object.entries(quantities)
.map(([priceId, quantity]) => ({ .map(([priceId, quantity]) => ({
raw_material_price_id: Number(priceId), raw_material_price_id: Number(priceId),
quantity: parseFloat(quantity) || 0, quantity: Number(quantity),
unit_price: priceMap.get(Number(priceId))?.price ?? 0, unit_price: priceMap.get(Number(priceId))?.price ?? 0,
})) }))
.filter((item) => item.quantity > 0), .filter((item) => item.quantity > 0),
@ -446,7 +418,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
variants: variantsRef.current.map((v) => ({ variants: variantsRef.current.map((v) => ({
variant: v.variant, variant: v.variant,
price: Number(v.price), price: Number(v.price),
stock: parseFloat(v.stock) || 0, stock: Number(v.stock),
photo_key: v.photo, photo_key: v.photo,
})), })),
}; };
@ -726,19 +698,17 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
* *
</span> </span>
</Label> </Label>
<Input <NumberInput
type="text"
inputMode="decimal"
value={ value={
variant.stock variant.stock
} }
onChange={( onValueChange={(
e, val,
) => ) =>
updateVariant( updateVariant(
variantIndex, variantIndex,
'stock', 'stock',
e.target.value, val,
) )
} }
/> />
@ -888,29 +858,19 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
{selectedMaterial && ( {selectedMaterial && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( {selectedMaterial.raw_material_prices.map(
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedMaterialVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : selectedMaterialVariants.map(
(price) => ( (price) => (
<div <div
key={ key={
price.id price.id
} }
className={ className={
(parseFloat( (quantities[
quantities[ price
price .id
.id ] ??
] ?? 0) >
'0', 0
) >
0)
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3'
} }
@ -961,13 +921,11 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
size="icon" size="icon"
disabled={ disabled={
!( !(
parseFloat( quantities[
quantities[ price
price .id
.id ] ??
] ?? 0
'0',
) > 0
) )
} }
onClick={() => onClick={() =>
@ -979,23 +937,21 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
<Input <NumberInput
type="text"
inputMode="decimal"
className="w-24 text-center" className="w-24 text-center"
value={ value={
quantities[ quantities[
price price
.id .id
] ?? ] ??
'0' 0
} }
onChange={( onValueChange={(
e, val,
) => ) =>
updateQuantity( updateQuantity(
price.id, price.id,
e.target.value, val,
) )
} }
/> />
@ -1173,7 +1129,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
(mode === 'existing' (mode === 'existing'
? Object.values( ? Object.values(
quantities, quantities,
).every((q) => (parseFloat(q) || 0) <= 0) ).every((q) => q <= 0)
: !name) : !name)
} }
> >
@ -1270,7 +1226,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
disabled={ disabled={
(parseFloat(item.quantity) || 0) <= 0 item.quantity <= 0
} }
onClick={() => onClick={() =>
item.onAdjust(-1) item.onAdjust(-1)
@ -1278,12 +1234,10 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
<Input <NumberInput
type="text"
inputMode="decimal"
className="w-20 text-center" className="w-20 text-center"
value={item.quantity} value={item.quantity}
onChange={(e) => item.onSet(e.target.value)} onValueChange={item.onSet}
/> />
<Button <Button
type="button" type="button"
@ -1299,7 +1253,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
<div className="text-right"> <div className="text-right">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
{formatCurrency( {formatCurrency(
item.price * (parseFloat(item.quantity) || 0), item.price * item.quantity,
)} )}
</span> </span>
</div> </div>

View File

@ -8,12 +8,13 @@ import {
ShoppingCart, ShoppingCart,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { FileUploadMultiple } from '@/components/inputs'; import { FileUploadMultiple } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs'; import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -26,7 +27,6 @@ import {
ComboboxList, ComboboxList,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -36,13 +36,12 @@ import {
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { import {
index as purchaseIndex, index as purchaseIndex,
update, update,
} from '@/routes/admin/manage/purchases'; } from '@/routes/admin/manage/purchases';
import type { PurchaseCreateData, PurchaseForEdit, RawMaterialVariant } from './columns'; import type { PurchaseCreateData, PurchaseForEdit } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -50,16 +49,16 @@ type CartLine = {
title: string; title: string;
subtitle: string; subtitle: string;
price: number; price: number;
quantity: string; quantity: number;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSet: (value: string) => void; onSet: (value: number) => void;
onRemove: () => void; onRemove: () => void;
}; };
type Props = { type Props = {
purchase: PurchaseForEdit; purchase: PurchaseForEdit;
suppliers: PurchaseCreateData['suppliers']; suppliers: PurchaseCreateData['suppliers'];
rawMaterials: { id: number; name: string; unit: string }[]; rawMaterials: PurchaseCreateData['rawMaterials'];
}; };
export default function PurchaseEdit({ export default function PurchaseEdit({
@ -85,11 +84,11 @@ export default function PurchaseEdit({
}); });
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [quantities, setQuantities] = useState<Record<number, string>>(() => const [quantities, setQuantities] = useState<Record<number, number>>(() =>
Object.fromEntries( Object.fromEntries(
Object.entries(purchase.existing_quantities).map(([id, qty]) => [ Object.entries(purchase.existing_quantities).map(([id, qty]) => [
Number(id), Number(id),
String(qty), qty,
]), ]),
), ),
); );
@ -97,68 +96,34 @@ export default function PurchaseEdit({
const [previewKey, setPreviewKey] = useState<string | null>(null); const [previewKey, setPreviewKey] = useState<string | null>(null);
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null); const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
const [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map()); const priceMap = useMemo(
const [loadingVariants, setLoadingVariants] = useState(false); () =>
new Map(
useEffect(() => { rawMaterials.flatMap((m) =>
const materialIds = purchase.existing_material_ids ?? []; m.raw_material_prices.map((p) => [p.id, p]),
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 materialByPriceId = useMemo(
const map = new Map<number, RawMaterialVariant & { materialName: string; materialUnit: string }>(); () =>
for (const [materialId, variants] of fetchedVariants) { new Map(
const material = rawMaterials.find((m) => m.id === materialId); rawMaterials.flatMap((m) =>
if (!material) continue; m.raw_material_prices.map((p) => [
for (const p of variants) { p.id,
map.set(p.id, { ...p, materialName: material.name, materialUnit: material.unit }); { name: m.name, unit: m.unit },
} ]),
} ),
return map; ),
}, [fetchedVariants, rawMaterials]); [rawMaterials],
);
const materialByPriceId = useMemo(() => {
const map = new Map<number, { name: string; unit: string }>();
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( const subtotal = Object.entries(quantities).reduce(
(sum, [priceId, quantity]) => { (sum, [priceId, quantity]) => {
const price = priceMap.get(Number(priceId)); const price = priceMap.get(Number(priceId));
return sum + (price ? price.price * (parseFloat(quantity) || 0) : 0); return sum + (price ? price.price * quantity : 0);
}, },
0, 0,
); );
@ -193,17 +158,17 @@ export default function PurchaseEdit({
const isMultiMaterial = purchasedVariantsByMaterial.size > 1; const isMultiMaterial = purchasedVariantsByMaterial.size > 1;
const updateQuantity = useCallback((priceId: number, value: string) => { const updateQuantity = useCallback((priceId: number, value: number) => {
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[priceId]: value, [priceId]: Math.max(0, value),
})); }));
}, []); }, []);
const incrementQuantity = useCallback((priceId: number, amount: number) => { const incrementQuantity = useCallback((priceId: number, amount: number) => {
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[priceId]: String(Math.max(0, (parseFloat(prev[priceId]) || 0) + amount)), [priceId]: Math.max(0, (prev[priceId] ?? 0) + amount),
})); }));
}, []); }, []);
@ -211,7 +176,7 @@ export default function PurchaseEdit({
const lines: CartLine[] = []; const lines: CartLine[] = [];
for (const [priceId, quantity] of Object.entries(quantities)) { for (const [priceId, quantity] of Object.entries(quantities)) {
if ((parseFloat(quantity) || 0) <= 0) { if (quantity <= 0) {
continue; continue;
} }
@ -229,7 +194,7 @@ export default function PurchaseEdit({
quantity, quantity,
onAdjust: (delta) => incrementQuantity(id, delta), onAdjust: (delta) => incrementQuantity(id, delta),
onSet: (value) => updateQuantity(id, value), onSet: (value) => updateQuantity(id, value),
onRemove: () => updateQuantity(id, '0'), onRemove: () => updateQuantity(id, 0),
}); });
} }
} }
@ -252,7 +217,7 @@ export default function PurchaseEdit({
existing_items: Object.entries(quantities) existing_items: Object.entries(quantities)
.map(([priceId, quantity]) => ({ .map(([priceId, quantity]) => ({
raw_material_price_id: Number(priceId), raw_material_price_id: Number(priceId),
quantity: parseFloat(quantity) || 0, quantity: Number(quantity),
unit_price: priceMap.get(Number(priceId))?.price ?? 0, unit_price: priceMap.get(Number(priceId))?.price ?? 0,
})) }))
.filter((item) => item.quantity > 0), .filter((item) => item.quantity > 0),
@ -338,7 +303,7 @@ export default function PurchaseEdit({
variant="outline" variant="outline"
size="icon" size="icon"
disabled={ disabled={
(parseFloat(quantities[price.id] ?? '0') || 0) <= 0 (quantities[price.id] ?? 0) <= 0
} }
onClick={() => onClick={() =>
incrementQuantity(price.id, -1) incrementQuantity(price.id, -1)
@ -346,13 +311,11 @@ export default function PurchaseEdit({
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
<Input <NumberInput
type="text"
inputMode="decimal"
className="w-24 text-center" className="w-24 text-center"
value={quantities[price.id] ?? '0'} value={quantities[price.id] ?? 0}
onChange={(e) => onValueChange={(val) =>
updateQuantity(price.id, e.target.value) updateQuantity(price.id, val)
} }
/> />
<Button <Button
@ -519,7 +482,7 @@ export default function PurchaseEdit({
!supplierId || !supplierId ||
Object.values( Object.values(
quantities, quantities,
).every((q) => (parseFloat(q) || 0) <= 0) ).every((q) => q <= 0)
} }
> >
{processing {processing
@ -615,7 +578,7 @@ export default function PurchaseEdit({
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
disabled={ disabled={
(parseFloat(item.quantity) || 0) <= 0 item.quantity <= 0
} }
onClick={() => onClick={() =>
item.onAdjust(-1) item.onAdjust(-1)
@ -623,12 +586,10 @@ export default function PurchaseEdit({
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
<Input <NumberInput
type="text"
inputMode="decimal"
className="w-20 text-center" className="w-20 text-center"
value={item.quantity} value={item.quantity}
onChange={(e) => item.onSet(e.target.value)} onValueChange={item.onSet}
/> />
<Button <Button
type="button" type="button"
@ -644,7 +605,7 @@ export default function PurchaseEdit({
<div className="text-right"> <div className="text-right">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
{formatCurrency( {formatCurrency(
item.price * (parseFloat(item.quantity) || 0), item.price * item.quantity,
)} )}
</span> </span>
</div> </div>

View File

@ -45,7 +45,6 @@ export type RestockForEdit = {
notes: string | null; notes: string | null;
photo_key: string | null; photo_key: string | null;
photo_url: string | null; photo_url: string | null;
existing_product_ids: number[];
items: { items: {
id: number; id: number;
product_variant_id: number; product_variant_id: number;
@ -54,19 +53,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 = { export type ProductForRestock = {
id: number; id: number;
name: string; 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 = { export type RestockCreateData = {

View File

@ -35,7 +35,7 @@ import { loadRestockDraft } from '@/lib/restock-draft';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as restockIndex, store } from '@/routes/admin/manage/restocks'; import { index as restockIndex, store } from '@/routes/admin/manage/restocks';
import type { ProductForRestock, ProductVariantForRestock, RestockCreateData } from './columns'; import type { ProductForRestock, RestockCreateData } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -82,8 +82,6 @@ export default function RestockCreate({ products }: Props) {
const [cartOpen, setCartOpen] = useState(false); const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null); const [previewKey, setPreviewKey] = useState<string | null>(null);
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null); const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
const [fetchedVariants, setFetchedVariants] = useState<Map<number, ProductVariantForRestock[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const draftData = useMemo( const draftData = useMemo(
() => ({ () => ({
@ -114,54 +112,16 @@ export default function RestockCreate({ products }: Props) {
[products, selectedProductId], [products, selectedProductId],
); );
const selectedProductVariants = useMemo( const variantById = useMemo(
() => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), () =>
[selectedProduct, fetchedVariants], new Map(
products.flatMap((p: ProductForRestock) =>
p.product_variants.map((v) => [v.id, v]),
),
),
[products],
); );
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<number, ProductVariantForRestock>();
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<number, string>();
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( const getUnitPrice = useCallback(
(variantId: number) => { (variantId: number) => {
const variant = variantById.get(variantId); const variant = variantById.get(variantId);
@ -334,16 +294,7 @@ return 0;
{selectedProduct && ( {selectedProduct && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( {selectedProduct.product_variants.map(
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedProductVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
selectedProductVariants.map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'good' stockType === 'good'
@ -459,7 +410,7 @@ return 0;
</div> </div>
); );
}, },
))} )}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@ -25,7 +25,7 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as restockIndex, update } from '@/routes/admin/manage/restocks'; import { index as restockIndex, update } from '@/routes/admin/manage/restocks';
import type { RestockCreateData, RestockForEdit, ProductVariantForRestock } from './columns'; import type { RestockCreateData, RestockForEdit } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -49,13 +49,16 @@ export default function RestockEdit({ restock, products }: Props) {
const [stockType, setStockType] = useState<'good' | 'reject'>( const [stockType, setStockType] = useState<'good' | 'reject'>(
restock.stock_type === 'reject' ? 'reject' : 'good', restock.stock_type === 'reject' ? 'reject' : 'good',
); );
const [selectedProductId, setSelectedProductId] = useState(() => { const selectedProductId = useMemo(() => {
const items = restock.items ?? []; const items = restock.items ?? [];
const product = products.find((p) => const product = products.find((p) =>
restock.existing_product_ids?.includes(p.id), p.product_variants.some((v) =>
items.some((i) => i.product_variant_id === v.id),
),
); );
return product ? String(product.id) : ''; return product ? String(product.id) : '';
}); }, [products, restock.items]);
const [quantities, setQuantities] = useState<Record<number, number>>(() => const [quantities, setQuantities] = useState<Record<number, number>>(() =>
Object.fromEntries( Object.fromEntries(
(restock.items ?? []).map((item) => [ (restock.items ?? []).map((item) => [
@ -71,8 +74,6 @@ export default function RestockEdit({ restock, products }: Props) {
const [cartOpen, setCartOpen] = useState(false); const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null); const [previewKey, setPreviewKey] = useState<string | null>(null);
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null); const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
const [fetchedVariants, setFetchedVariants] = useState<Map<number, ProductVariantForRestock[]>>(() => new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const quantitiesRef = useRef(quantities); const quantitiesRef = useRef(quantities);
@ -85,84 +86,25 @@ export default function RestockEdit({ restock, products }: Props) {
[products, selectedProductId], [products, selectedProductId],
); );
const selectedProductVariants = useMemo( const variantById = useMemo(
() => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), () =>
[selectedProduct, fetchedVariants], new Map(
products.flatMap((p) =>
p.product_variants.map((v) => [v.id, v]),
),
),
[products],
); );
useEffect(() => { const productByVariantId = useMemo(
if (!selectedProduct) return; () =>
if (fetchedVariants.has(selectedProduct.id)) return; new Map(
products.flatMap((p) =>
setLoadingVariants(true); p.product_variants.map((v) => [v.id, p.name]),
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<number, ProductVariantForRestock>();
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<number, string>();
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( const getUnitPrice = useCallback(
(variantId: number) => { (variantId: number) => {
@ -290,16 +232,7 @@ return 0;
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{selectedProduct ? ( {selectedProduct ? (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( {selectedProduct.product_variants.map(
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedProductVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
selectedProductVariants.map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'good' stockType === 'good'
@ -415,14 +348,13 @@ return 0;
</div> </div>
); );
}, },
) )}
)} </div>
</div> ) : (
) : ( <p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground"> Tidak ada item.
Tidak ada item. </p>
</p> )}
)}
<InputError message={errors.items} /> <InputError message={errors.items} />
</CardContent> </CardContent>
</Card> </Card>

View File

@ -98,24 +98,21 @@ export type TransactionForEdit = {
quantity: number; quantity: number;
unit_price: number; unit_price: number;
}[]; }[];
existing_product_ids: number[];
}; };
export type ProductForTransaction = { export type ProductForTransaction = {
id: number; id: number;
name: string; name: string;
}; status: string;
product_variants: {
export type ProductVariantForTransaction = { id: number;
id: number; name: string;
product_id: number; stock: number;
name: string; reject_stock: number;
stock: number; retail_stock: number;
reject_stock: number; photo_url: string | null;
retail_stock: number; prices: Record<string, number>;
photo_url: string | null; }[];
photo_conversion_url: string | null;
prices: Record<string, number>;
}; };
export type CustomerForTransaction = { export type CustomerForTransaction = {

View File

@ -47,7 +47,7 @@ import { loadTransactionDraft } from '@/lib/transaction-draft';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { store, index as transactionIndex } from '@/routes/admin/manage/transactions'; import { store, index as transactionIndex } from '@/routes/admin/manage/transactions';
import type { ProductForTransaction, ProductVariantForTransaction, TransactionCreateData } from './columns'; import type { ProductForTransaction, TransactionCreateData } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -119,8 +119,6 @@ export default function TransactionCreate({
const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? ''); const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? '');
const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? ''); const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? '');
const [variantSearch, setVariantSearch] = useState(''); const [variantSearch, setVariantSearch] = useState('');
const [fetchedVariants, setFetchedVariants] = useState<Map<number, ProductVariantForTransaction[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const quantitiesRef = useRef(quantities); const quantitiesRef = useRef(quantities);
@ -166,64 +164,38 @@ export default function TransactionCreate({
[products, selectedProductId], [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(() => { const groupedVariants = useMemo(() => {
if (!variantSearch) return []; if (!variantSearch) return [];
if (!selectedProduct) return [];
const search = variantSearch.toLowerCase(); const search = variantSearch.toLowerCase();
return [{ return products
...selectedProduct, .map((p) => ({
product_variants: selectedProductVariants.filter((v) => ...p,
v.name.toLowerCase().includes(search), 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]),
),
), ),
}]; [products],
}, [selectedProduct, selectedProductVariants, variantSearch]); );
const variantById = useMemo(() => { const productByVariantId = useMemo(
const map = new Map<number, ProductVariantForTransaction>(); () =>
for (const [, variants] of fetchedVariants) { new Map(
for (const v of variants) { products.flatMap((p: ProductForTransaction) =>
map.set(v.id, v); p.product_variants.map((v) => [v.id, p.name]),
} ),
} ),
return map; [products],
}, [fetchedVariants]); );
const productByVariantId = useMemo(() => {
const map = new Map<number, string>();
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(() => { useEffect(() => {
if (channel === 'tiktok') { if (channel === 'tiktok') {
@ -536,16 +508,7 @@ export default function TransactionCreate({
{!variantSearch && selectedProduct && ( {!variantSearch && selectedProduct && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( {selectedProduct.product_variants.map(
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : selectedProductVariants.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
selectedProductVariants.map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'reject' stockType === 'reject'
@ -565,10 +528,10 @@ export default function TransactionCreate({
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{variant.photo_conversion_url ?? variant.photo_url ? ( {variant.photo_url ? (
<img <img
src={ src={
variant.photo_conversion_url ?? variant.photo_url variant.photo_url
} }
alt={ alt={
variant.name variant.name
@ -666,10 +629,9 @@ export default function TransactionCreate({
</div> </div>
); );
}, },
) )}
)} </div>
</div> )}
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -44,7 +44,7 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions'; import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
import type { ProductVariantForTransaction, TransactionCreateData, TransactionForEdit } from './columns'; import type { TransactionCreateData, TransactionForEdit } from './columns';
type CartLine = { type CartLine = {
key: string; key: string;
@ -98,11 +98,15 @@ export default function TransactionEdit({
const [photo, setPhoto] = useState<string | null>(transaction.photo_key); const [photo, setPhoto] = useState<string | null>(transaction.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(transaction.photo_url); const [photoUrl, setPhotoUrl] = useState<string | null>(transaction.photo_url);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [fetchedVariants, setFetchedVariants] = useState<Map<number, ProductVariantForTransaction[]>>(new Map());
const [loadingVariants, setLoadingVariants] = useState(false);
const [selectedProductId, setSelectedProductId] = useState(() => { const [selectedProductId, setSelectedProductId] = useState(() => {
const firstProductId = transaction.existing_product_ids?.[0]; const items = transaction.items ?? [];
return firstProductId ? String(firstProductId) : ''; 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 [quantities, setQuantities] = useState<Record<number, number>>(() => const [quantities, setQuantities] = useState<Record<number, number>>(() =>
Object.fromEntries( Object.fromEntries(
@ -152,77 +156,25 @@ export default function TransactionEdit({
[products, selectedProductId], [products, selectedProductId],
); );
useEffect(() => { const variantById = useMemo(
const productIds = transaction.existing_product_ids ?? []; () =>
if (productIds.length === 0) return; new Map(
products.flatMap((p) =>
const toFetch = productIds.filter((id) => !fetchedVariants.has(id)); p.product_variants.map((v) => [v.id, v]),
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(() => { const productByVariantId = useMemo(
if (!selectedProduct) return; () =>
if (fetchedVariants.has(selectedProduct.id)) return; new Map(
products.flatMap((p) =>
setLoadingVariants(true); p.product_variants.map((v) => [v.id, p.name]),
fetch(`/admin/master/products/${selectedProduct.id}/variants`) ),
.then((res) => res.json()) ),
.then((data) => { [products],
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<number, ProductVariantForTransaction>();
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<number, string>();
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'; const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
@ -445,16 +397,7 @@ export default function TransactionEdit({
{selectedProduct && ( {selectedProduct && (
<div className="space-y-2"> <div className="space-y-2">
{loadingVariants ? ( {selectedProduct.product_variants.map(
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Memuat varian...
</div>
) : fetchedVariants.get(selectedProduct.id)?.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Tidak ada varian ditemukan.
</div>
) : (
(fetchedVariants.get(selectedProduct.id) ?? []).map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'reject' stockType === 'reject'
@ -575,10 +518,9 @@ export default function TransactionEdit({
</div> </div>
); );
}, },
) )}
)} </div>
</div> )}
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -44,7 +44,6 @@
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:categories.view|categories.create|categories.update|categories.delete'); 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::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-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}/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'); Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update');
@ -58,7 +57,6 @@
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations')->middleware('permission:products.view_stock_mutations'); 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::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::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', [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'); Route::get('raw-materials/{rawMaterial}/variants/{variant}/edit', [RawMaterialVariantController::class, 'edit'])->name('raw-materials.variants.edit')->middleware('permission:raw_materials.update');