Compare commits
3 Commits
9fead3dfa9
...
f128ace2ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f128ace2ea | ||
|
|
b85bbb1ba4 | ||
|
|
051851b90a |
@ -8,6 +8,7 @@
|
|||||||
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;
|
||||||
@ -18,6 +19,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -40,7 +42,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->rawMaterialVariantService->getForCutting(),
|
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +74,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->rawMaterialVariantService->getForCutting(),
|
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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;
|
||||||
@ -19,6 +20,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -39,7 +41,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->rawMaterialVariantService->getForCutting(),
|
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +60,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->rawMaterialVariantService->getForCutting(),
|
'rawMaterials' => $this->rawMaterialService->getActiveMaterials(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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;
|
||||||
@ -17,6 +18,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -33,7 +35,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->productVariantService->getForRestock(),
|
'products' => $this->productService->getActiveProducts(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +53,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->productVariantService->getForRestock(),
|
'products' => $this->productService->getActiveProducts(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
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;
|
||||||
@ -23,6 +24,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
@ -50,7 +52,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->productVariantService->getForTransaction(),
|
'products' => $this->productService->getActiveProducts(),
|
||||||
'customers' => $this->customerService->getAll(),
|
'customers' => $this->customerService->getAll(),
|
||||||
'employees' => $this->getEmployees(),
|
'employees' => $this->getEmployees(),
|
||||||
'channelOptions' => OrderChannel::toSelect(),
|
'channelOptions' => OrderChannel::toSelect(),
|
||||||
@ -80,7 +82,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->productVariantService->getForTransaction(),
|
'products' => $this->productService->getActiveProducts(),
|
||||||
'customers' => $this->customerService->getAll(),
|
'customers' => $this->customerService->getAll(),
|
||||||
'employees' => $this->getEmployees(),
|
'employees' => $this->getEmployees(),
|
||||||
'channelOptions' => OrderChannel::toSelect(),
|
'channelOptions' => OrderChannel::toSelect(),
|
||||||
|
|||||||
@ -133,4 +133,11 @@ 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,4 +92,11 @@ 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'), 'integer', 'min:0'],
|
'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'numeric', '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', 'integer', 'min:1'],
|
'existing_items.*.quantity' => ['required', 'numeric', 'min:0.01'],
|
||||||
'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'],
|
||||||
|
|||||||
@ -19,9 +19,9 @@ class PurchaseItem extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'quantity' => 'integer',
|
'quantity' => 'decimal:2',
|
||||||
'unit_price' => 'integer',
|
'unit_price' => 'integer',
|
||||||
'subtotal' => 'integer',
|
'subtotal' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -257,6 +257,12 @@ 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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -151,11 +151,13 @@ 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) => $this->s3Service->getTemporaryUrl($media->getPath())
|
fn ($media) => $media->getGeneratedConversions()->contains('thumb')
|
||||||
|
? $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 => (int) $item->quantity,
|
(int) $item->raw_material_price_id => (string) $item->quantity,
|
||||||
])->all();
|
])->all();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -172,6 +174,7 @@ 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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,7 +194,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 = (int) ($item['unit_price'] * $item['quantity']);
|
$itemSubtotal = $item['unit_price'] * $item['quantity'];
|
||||||
$subtotal += $itemSubtotal;
|
$subtotal += $itemSubtotal;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -264,7 +267,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 = (int) ($v['price'] * $v['stock']);
|
$itemSubtotal = $v['price'] * $v['stock'];
|
||||||
$subtotal += $itemSubtotal;
|
$subtotal += $itemSubtotal;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -322,7 +325,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' => (int) ($v['price'] * $v['stock']),
|
'subtotal' => $v['price'] * $v['stock'],
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
];
|
];
|
||||||
@ -375,7 +378,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 = (int) ($item['unit_price'] * $item['quantity']);
|
$itemSubtotal = $item['unit_price'] * $item['quantity'];
|
||||||
$subtotal += $itemSubtotal;
|
$subtotal += $itemSubtotal;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -426,7 +429,7 @@ public function update(Purchase $purchase, array $data): Purchase
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($price) {
|
if ($price) {
|
||||||
$price->increment('stock', (int) $v['stock']);
|
$price->increment('stock', $v['stock']);
|
||||||
$price->update(['price' => $v['price']]);
|
$price->update(['price' => $v['price']]);
|
||||||
} else {
|
} else {
|
||||||
$price = $rawMaterial->rawMaterialPrices()->create([
|
$price = $rawMaterial->rawMaterialPrices()->create([
|
||||||
@ -446,7 +449,7 @@ public function update(Purchase $purchase, array $data): Purchase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$itemSubtotal = (int) ($v['price'] * $v['stock']);
|
$itemSubtotal = $v['price'] * $v['stock'];
|
||||||
$subtotal += $itemSubtotal;
|
$subtotal += $itemSubtotal;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -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,name',
|
'restockItems.productVariant:id,product_id,name',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$media = $restock->getFirstMedia('photos');
|
$media = $restock->getFirstMedia('photos');
|
||||||
@ -83,6 +83,12 @@ 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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,7 @@ 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';
|
||||||
@ -138,6 +139,12 @@ 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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,14 @@ 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()
|
||||||
|
|||||||
@ -60,6 +60,36 @@ 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()
|
||||||
|
|||||||
@ -57,6 +57,14 @@ 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()
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -10,12 +10,12 @@ export type PurchaseDraftData = {
|
|||||||
variants: Array<{
|
variants: Array<{
|
||||||
variant: string;
|
variant: string;
|
||||||
price: number;
|
price: number;
|
||||||
stock: number;
|
stock: string;
|
||||||
photo_keys?: string[];
|
photo_keys?: string[];
|
||||||
}>;
|
}>;
|
||||||
mode?: 'new' | 'existing';
|
mode?: 'new' | 'existing';
|
||||||
selectedMaterialName?: string;
|
selectedMaterialName?: string;
|
||||||
quantities?: Record<string, number>;
|
quantities?: Record<string, string>;
|
||||||
photo?: string;
|
photo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,7 @@ 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 = {
|
||||||
@ -98,14 +99,15 @@ 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;
|
||||||
|
};
|
||||||
|
|||||||
@ -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, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, 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 } from './columns';
|
import type { CuttingCreateData, RawMaterialVariant } from './columns';
|
||||||
|
|
||||||
type MaterialState = {
|
type MaterialState = {
|
||||||
raw_material_price_id: number;
|
raw_material_price_id: number;
|
||||||
@ -79,6 +79,8 @@ 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);
|
||||||
@ -148,32 +150,87 @@ 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(() => {
|
||||||
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
|
const map = new Map<number, RawMaterialVariant>();
|
||||||
[rawMaterials],
|
for (const [, variants] of fetchedVariants) {
|
||||||
);
|
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 rawMaterials
|
return [{
|
||||||
.map((rm) => ({
|
...selectedMaterial,
|
||||||
...rm,
|
raw_material_prices: selectedMaterialVariants.filter(
|
||||||
raw_material_prices: rm.raw_material_prices.filter(
|
(p) => p.variant.toLowerCase().includes(search),
|
||||||
(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: (typeof rawMaterials)[number], priceId: number) => {
|
(rawMaterial: { id: number; name: string; unit: string }, priceId: number) => {
|
||||||
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
|
const variants = fetchedVariants.get(rawMaterial.id) ?? [];
|
||||||
|
const price = variants.find((p) => p.id === priceId);
|
||||||
|
|
||||||
if (!price) {
|
if (!price) {
|
||||||
return;
|
return;
|
||||||
@ -200,7 +257,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[fetchedVariants],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
|
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
|
||||||
@ -223,30 +280,22 @@ 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) => {
|
||||||
let foundMaterial: typeof rawMaterials[number] | undefined;
|
const price = variants.find((p) => p.id === priceId);
|
||||||
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: foundPrice?.variant ?? '',
|
variant: price?.variant ?? '',
|
||||||
material_name: foundMaterial?.name ?? '',
|
material_name: comboMaterialData?.name ?? '',
|
||||||
unit: foundMaterial?.unit ?? '',
|
unit: comboMaterialData?.unit ?? '',
|
||||||
photo_url: foundPrice?.photo_url ?? null,
|
photo_url: price?.photo_url ?? null,
|
||||||
photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
|
photo_conversion_url: price?.photo_conversion_url ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -262,7 +311,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
|||||||
setComboMaterialName('');
|
setComboMaterialName('');
|
||||||
setComboSelectedPriceIds([]);
|
setComboSelectedPriceIds([]);
|
||||||
setComboResult(0);
|
setComboResult(0);
|
||||||
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
|
}, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]);
|
||||||
|
|
||||||
const removeMaterial = useCallback((index: number) => {
|
const removeMaterial = useCallback((index: number) => {
|
||||||
setDeleteMaterialIndex(index);
|
setDeleteMaterialIndex(index);
|
||||||
@ -434,43 +483,53 @@ 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 && selectedMaterial.raw_material_prices.length > 0 && (
|
{!variantSearch && selectedMaterial && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
{loadingVariants ? (
|
||||||
{selectedMaterial.raw_material_prices.map((price) => {
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
Memuat varian...
|
||||||
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
|
</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">
|
||||||
|
{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)}>
|
})}
|
||||||
<Plus className="h-4 w-4" />
|
</div>
|
||||||
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>
|
||||||
@ -740,18 +799,13 @@ 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 = '';
|
let materialName = comboMaterialName;
|
||||||
let photoUrl: string | null = null;
|
let photoUrl: string | null = null;
|
||||||
|
|
||||||
for (const rm of rawMaterials) {
|
const price = comboMaterialVariants.find((pp) => pp.id === priceId);
|
||||||
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
|
if (price) {
|
||||||
|
variantName = price.variant;
|
||||||
if (p) {
|
photoUrl = price.photo_conversion_url ?? price.photo_url;
|
||||||
variantName = p.variant;
|
|
||||||
materialName = rm.name;
|
|
||||||
photoUrl = p.photo_conversion_url ?? p.photo_url;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -777,33 +831,43 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
|
{comboMaterial && (
|
||||||
<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">
|
{loadingVariants ? (
|
||||||
{comboMaterial.raw_material_prices.map((price) => {
|
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
|
||||||
const isSelected = comboSelectedPriceIds.includes(price.id);
|
Memuat varian...
|
||||||
|
</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'}
|
})}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, 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 } from './columns';
|
import type { CuttingCreateData, CuttingForEdit, RawMaterialVariant } from './columns';
|
||||||
|
|
||||||
type MaterialState = {
|
type MaterialState = {
|
||||||
id?: number;
|
id?: number;
|
||||||
@ -54,13 +54,6 @@ 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,
|
||||||
@ -70,11 +63,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: price?.variant ?? m.variant ?? '',
|
variant: m.variant ?? '',
|
||||||
material_name: rawMaterial?.name ?? '',
|
material_name: '',
|
||||||
unit: rawMaterial?.unit ?? '',
|
unit: '',
|
||||||
photo_url: price?.photo_url ?? m.photo_url ?? null,
|
photo_url: m.photo_url ?? null,
|
||||||
photo_conversion_url: price?.photo_conversion_url ?? m.photo_conversion_url ?? null,
|
photo_conversion_url: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -89,6 +82,8 @@ 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);
|
||||||
@ -127,35 +122,120 @@ 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(() => {
|
||||||
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
|
const map = new Map<number, RawMaterialVariant>();
|
||||||
[rawMaterials],
|
for (const [, variants] of fetchedVariants) {
|
||||||
);
|
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 rawMaterials
|
return [{
|
||||||
.map((rm) => ({
|
...selectedMaterial,
|
||||||
...rm,
|
raw_material_prices: selectedMaterialVariants.filter(
|
||||||
raw_material_prices: rm.raw_material_prices.filter(
|
(p) => p.variant.toLowerCase().includes(search),
|
||||||
(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: (typeof rawMaterials)[number], priceId: number) => {
|
(rawMaterial: { id: number; name: string; unit: string }, priceId: number) => {
|
||||||
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId);
|
const variants = fetchedVariants.get(rawMaterial.id) ?? [];
|
||||||
|
const price = variants.find((p) => p.id === priceId);
|
||||||
|
|
||||||
if (!price) {
|
if (!price) {
|
||||||
return;
|
return;
|
||||||
@ -205,30 +285,22 @@ 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) => {
|
||||||
let foundMaterial: (typeof rawMaterials)[number] | undefined;
|
const price = variants.find((p) => p.id === priceId);
|
||||||
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: foundPrice?.variant ?? '',
|
variant: price?.variant ?? '',
|
||||||
material_name: foundMaterial?.name ?? '',
|
material_name: comboMaterialData?.name ?? '',
|
||||||
unit: foundMaterial?.unit ?? '',
|
unit: comboMaterialData?.unit ?? '',
|
||||||
photo_url: foundPrice?.photo_url ?? null,
|
photo_url: price?.photo_url ?? null,
|
||||||
photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
|
photo_conversion_url: price?.photo_conversion_url ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -244,7 +316,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
|||||||
setComboMaterialName('');
|
setComboMaterialName('');
|
||||||
setComboSelectedPriceIds([]);
|
setComboSelectedPriceIds([]);
|
||||||
setComboResult(0);
|
setComboResult(0);
|
||||||
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
|
}, [comboSelectedPriceIds, comboResult, rawMaterials, fetchedVariants, comboMaterialName, combinations.length]);
|
||||||
|
|
||||||
const updateMaterial = useCallback(
|
const updateMaterial = useCallback(
|
||||||
(index: number, field: keyof MaterialState, value: unknown) => {
|
(index: number, field: keyof MaterialState, value: unknown) => {
|
||||||
@ -368,7 +440,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">
|
||||||
{rm.raw_material_prices.map((price) => {
|
{selectedMaterialVariants.filter((p) => p.variant.toLowerCase().includes(variantSearch.toLowerCase())).map((price) => {
|
||||||
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
|
const 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;
|
||||||
|
|
||||||
@ -411,10 +483,19 @@ 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 && selectedMaterial.raw_material_prices.length > 0 && (
|
{!variantSearch && selectedMaterial && (
|
||||||
<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">
|
||||||
{selectedMaterial.raw_material_prices.map((price) => {
|
{selectedMaterialVariants.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;
|
||||||
|
|
||||||
@ -448,6 +529,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -716,20 +798,10 @@ 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) => {
|
||||||
let variantName = '';
|
const p = comboMaterialVariants.find((pp) => pp.id === priceId);
|
||||||
let materialName = '';
|
const variantName = p?.variant ?? '';
|
||||||
let photoUrl: string | null = null;
|
const materialName = comboMaterialName;
|
||||||
|
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">
|
||||||
@ -754,11 +826,11 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
|
{comboMaterial && comboMaterialVariants.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">
|
||||||
{comboMaterial.raw_material_prices.map((price) => {
|
{comboMaterialVariants.map((price) => {
|
||||||
const isSelected = comboSelectedPriceIds.includes(price.id);
|
const isSelected = comboSelectedPriceIds.includes(price.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -79,6 +79,7 @@ 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 = {
|
||||||
@ -92,14 +93,15 @@ 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;
|
||||||
|
};
|
||||||
|
|||||||
@ -11,13 +11,12 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, 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';
|
||||||
@ -48,12 +47,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 } from './columns';
|
import type { PurchaseCreateData, RawMaterialVariant } from './columns';
|
||||||
|
|
||||||
type VariantState = {
|
type VariantState = {
|
||||||
variant: string;
|
variant: string;
|
||||||
price: number;
|
price: number;
|
||||||
stock: number;
|
stock: string;
|
||||||
photo: string | null;
|
photo: string | null;
|
||||||
photoUrl: string | null;
|
photoUrl: string | null;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
@ -65,15 +64,15 @@ type CartLine = {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: string;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
onSet: (value: number) => void;
|
onSet: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
suppliers: PurchaseCreateData['suppliers'];
|
suppliers: PurchaseCreateData['suppliers'];
|
||||||
rawMaterials: PurchaseCreateData['rawMaterials'];
|
rawMaterials: { id: number; name: string; unit: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
||||||
@ -89,7 +88,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: v.stock,
|
stock: String(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,
|
||||||
@ -100,7 +99,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,
|
||||||
@ -129,11 +128,14 @@ 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),
|
||||||
qty,
|
String(qty),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -185,38 +187,39 @@ 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 }>();
|
||||||
new Map(
|
for (const [materialId, variants] of fetchedVariants) {
|
||||||
rawMaterials.flatMap((m) =>
|
const material = rawMaterials.find((m) => m.id === materialId);
|
||||||
m.raw_material_prices.map((p) => [p.id, p]),
|
if (!material) continue;
|
||||||
),
|
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 }>();
|
||||||
new Map(
|
for (const [materialId, variants] of fetchedVariants) {
|
||||||
rawMaterials.flatMap((m) =>
|
const material = rawMaterials.find((m) => m.id === materialId);
|
||||||
m.raw_material_prices.map((p) => [
|
if (!material) continue;
|
||||||
p.id,
|
for (const p of variants) {
|
||||||
{ name: m.name, unit: m.unit },
|
map.set(p.id, { name: material.name, unit: material.unit });
|
||||||
]),
|
}
|
||||||
),
|
}
|
||||||
),
|
return map;
|
||||||
[rawMaterials],
|
}, [fetchedVariants, rawMaterials]);
|
||||||
);
|
|
||||||
|
|
||||||
const newSubtotal = variants.reduce(
|
const newSubtotal = variants.reduce(
|
||||||
(sum, v) => sum + Number(v.price) * Number(v.stock),
|
(sum, v) => sum + Number(v.price) * (parseFloat(v.stock) || 0),
|
||||||
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 * quantity : 0);
|
return sum + (price ? price.price * (parseFloat(quantity) || 0) : 0);
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -229,7 +232,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,
|
||||||
@ -312,17 +315,42 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
[rawMaterials, selectedMaterialName],
|
[rawMaterials, selectedMaterialName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateQuantity = useCallback((priceId: number, value: number) => {
|
const selectedMaterialVariants = useMemo(
|
||||||
|
() => (selectedMaterial ? fetchedVariants.get(selectedMaterial.id) ?? [] : []),
|
||||||
|
[selectedMaterial, fetchedVariants],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMaterial || mode !== 'existing') return;
|
||||||
|
if (fetchedVariants.has(selectedMaterial.id)) return;
|
||||||
|
|
||||||
|
setLoadingVariants(true);
|
||||||
|
fetch(`/admin/master/raw-materials/${selectedMaterial.id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(selectedMaterial.id, data.variants);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian bahan baku.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [selectedMaterial, mode, fetchedVariants]);
|
||||||
|
|
||||||
|
const updateQuantity = useCallback((priceId: number, value: string) => {
|
||||||
setQuantities((prev) => ({
|
setQuantities((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[priceId]: Math.max(0, value),
|
[priceId]: value,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const incrementQuantity = useCallback((priceId: number, amount: number) => {
|
const incrementQuantity = useCallback((priceId: number, amount: number) => {
|
||||||
setQuantities((prev) => ({
|
setQuantities((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[priceId]: Math.max(0, (prev[priceId] ?? 0) + amount),
|
[priceId]: String(Math.max(0, (parseFloat(prev[priceId]) || 0) + amount)),
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -331,7 +359,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[index] = {
|
updated[index] = {
|
||||||
...updated[index],
|
...updated[index],
|
||||||
stock: Math.max(0, Number(updated[index].stock) + amount),
|
stock: String(Math.max(0, (parseFloat(updated[index].stock) || 0) + amount)),
|
||||||
};
|
};
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
@ -343,7 +371,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 (quantity <= 0) {
|
if ((parseFloat(quantity) || 0) <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,7 +389,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'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -381,7 +409,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) => line.quantity > 0);
|
.filter((line) => (parseFloat(line.quantity) || 0) > 0);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
@ -404,7 +432,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: Number(quantity),
|
quantity: parseFloat(quantity) || 0,
|
||||||
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),
|
||||||
@ -418,7 +446,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: Number(v.stock),
|
stock: parseFloat(v.stock) || 0,
|
||||||
photo_key: v.photo,
|
photo_key: v.photo,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@ -698,17 +726,19 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<NumberInput
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
value={
|
value={
|
||||||
variant.stock
|
variant.stock
|
||||||
}
|
}
|
||||||
onValueChange={(
|
onChange={(
|
||||||
val,
|
e,
|
||||||
) =>
|
) =>
|
||||||
updateVariant(
|
updateVariant(
|
||||||
variantIndex,
|
variantIndex,
|
||||||
'stock',
|
'stock',
|
||||||
val,
|
e.target.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -858,19 +888,29 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
|
|
||||||
{selectedMaterial && (
|
{selectedMaterial && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedMaterial.raw_material_prices.map(
|
{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>
|
||||||
|
) : selectedMaterialVariants.map(
|
||||||
(price) => (
|
(price) => (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
price.id
|
price.id
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
(quantities[
|
(parseFloat(
|
||||||
price
|
quantities[
|
||||||
.id
|
price
|
||||||
] ??
|
.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'
|
||||||
}
|
}
|
||||||
@ -921,11 +961,13 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
disabled={
|
disabled={
|
||||||
!(
|
!(
|
||||||
quantities[
|
parseFloat(
|
||||||
price
|
quantities[
|
||||||
.id
|
price
|
||||||
] ??
|
.id
|
||||||
0
|
] ??
|
||||||
|
'0',
|
||||||
|
) > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -937,21 +979,23 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={
|
value={
|
||||||
quantities[
|
quantities[
|
||||||
price
|
price
|
||||||
.id
|
.id
|
||||||
] ??
|
] ??
|
||||||
0
|
'0'
|
||||||
}
|
}
|
||||||
onValueChange={(
|
onChange={(
|
||||||
val,
|
e,
|
||||||
) =>
|
) =>
|
||||||
updateQuantity(
|
updateQuantity(
|
||||||
price.id,
|
price.id,
|
||||||
val,
|
e.target.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -1129,7 +1173,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
(mode === 'existing'
|
(mode === 'existing'
|
||||||
? Object.values(
|
? Object.values(
|
||||||
quantities,
|
quantities,
|
||||||
).every((q) => q <= 0)
|
).every((q) => (parseFloat(q) || 0) <= 0)
|
||||||
: !name)
|
: !name)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -1226,7 +1270,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={
|
disabled={
|
||||||
item.quantity <= 0
|
(parseFloat(item.quantity) || 0) <= 0
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
item.onAdjust(-1)
|
item.onAdjust(-1)
|
||||||
@ -1234,10 +1278,12 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
className="w-20 text-center"
|
className="w-20 text-center"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onValueChange={item.onSet}
|
onChange={(e) => item.onSet(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1253,7 +1299,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 * item.quantity,
|
item.price * (parseFloat(item.quantity) || 0),
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,13 +8,12 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, 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';
|
||||||
@ -27,6 +26,7 @@ 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,12 +36,13 @@ 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 } from './columns';
|
import type { PurchaseCreateData, PurchaseForEdit, RawMaterialVariant } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -49,16 +50,16 @@ type CartLine = {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: string;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
onSet: (value: number) => void;
|
onSet: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
purchase: PurchaseForEdit;
|
purchase: PurchaseForEdit;
|
||||||
suppliers: PurchaseCreateData['suppliers'];
|
suppliers: PurchaseCreateData['suppliers'];
|
||||||
rawMaterials: PurchaseCreateData['rawMaterials'];
|
rawMaterials: { id: number; name: string; unit: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseEdit({
|
export default function PurchaseEdit({
|
||||||
@ -84,11 +85,11 @@ export default function PurchaseEdit({
|
|||||||
});
|
});
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
const [quantities, setQuantities] = useState<Record<number, string>>(() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(purchase.existing_quantities).map(([id, qty]) => [
|
Object.entries(purchase.existing_quantities).map(([id, qty]) => [
|
||||||
Number(id),
|
Number(id),
|
||||||
qty,
|
String(qty),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -96,34 +97,68 @@ 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 priceMap = useMemo(
|
const [fetchedVariants, setFetchedVariants] = useState<Map<number, RawMaterialVariant[]>>(new Map());
|
||||||
() =>
|
const [loadingVariants, setLoadingVariants] = useState(false);
|
||||||
new Map(
|
|
||||||
rawMaterials.flatMap((m) =>
|
|
||||||
m.raw_material_prices.map((p) => [p.id, p]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[rawMaterials],
|
|
||||||
);
|
|
||||||
|
|
||||||
const materialByPriceId = useMemo(
|
useEffect(() => {
|
||||||
() =>
|
const materialIds = purchase.existing_material_ids ?? [];
|
||||||
new Map(
|
if (materialIds.length === 0) return;
|
||||||
rawMaterials.flatMap((m) =>
|
|
||||||
m.raw_material_prices.map((p) => [
|
const toFetch = materialIds.filter((id) => !fetchedVariants.has(id));
|
||||||
p.id,
|
if (toFetch.length === 0) return;
|
||||||
{ name: m.name, unit: m.unit },
|
|
||||||
]),
|
setLoadingVariants(true);
|
||||||
),
|
Promise.all(
|
||||||
|
toFetch.map((id) =>
|
||||||
|
fetch(`/admin/master/raw-materials/${id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => [id, data.variants] as const)
|
||||||
),
|
),
|
||||||
[rawMaterials],
|
)
|
||||||
);
|
.then((results) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [id, variants] of results) {
|
||||||
|
next.set(id, variants);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian bahan baku.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [purchase.existing_material_ids]);
|
||||||
|
|
||||||
|
const priceMap = useMemo(() => {
|
||||||
|
const map = new Map<number, RawMaterialVariant & { materialName: string; materialUnit: 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, { ...p, materialName: material.name, materialUnit: material.unit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [fetchedVariants, 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 * quantity : 0);
|
return sum + (price ? price.price * (parseFloat(quantity) || 0) : 0);
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -158,17 +193,17 @@ export default function PurchaseEdit({
|
|||||||
|
|
||||||
const isMultiMaterial = purchasedVariantsByMaterial.size > 1;
|
const isMultiMaterial = purchasedVariantsByMaterial.size > 1;
|
||||||
|
|
||||||
const updateQuantity = useCallback((priceId: number, value: number) => {
|
const updateQuantity = useCallback((priceId: number, value: string) => {
|
||||||
setQuantities((prev) => ({
|
setQuantities((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[priceId]: Math.max(0, value),
|
[priceId]: value,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const incrementQuantity = useCallback((priceId: number, amount: number) => {
|
const incrementQuantity = useCallback((priceId: number, amount: number) => {
|
||||||
setQuantities((prev) => ({
|
setQuantities((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[priceId]: Math.max(0, (prev[priceId] ?? 0) + amount),
|
[priceId]: String(Math.max(0, (parseFloat(prev[priceId]) || 0) + amount)),
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -176,7 +211,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 (quantity <= 0) {
|
if ((parseFloat(quantity) || 0) <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +229,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'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,7 +252,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: Number(quantity),
|
quantity: parseFloat(quantity) || 0,
|
||||||
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),
|
||||||
@ -303,7 +338,7 @@ export default function PurchaseEdit({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={
|
disabled={
|
||||||
(quantities[price.id] ?? 0) <= 0
|
(parseFloat(quantities[price.id] ?? '0') || 0) <= 0
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
incrementQuantity(price.id, -1)
|
incrementQuantity(price.id, -1)
|
||||||
@ -311,11 +346,13 @@ export default function PurchaseEdit({
|
|||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
className="w-24 text-center"
|
className="w-24 text-center"
|
||||||
value={quantities[price.id] ?? 0}
|
value={quantities[price.id] ?? '0'}
|
||||||
onValueChange={(val) =>
|
onChange={(e) =>
|
||||||
updateQuantity(price.id, val)
|
updateQuantity(price.id, e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@ -482,7 +519,7 @@ export default function PurchaseEdit({
|
|||||||
!supplierId ||
|
!supplierId ||
|
||||||
Object.values(
|
Object.values(
|
||||||
quantities,
|
quantities,
|
||||||
).every((q) => q <= 0)
|
).every((q) => (parseFloat(q) || 0) <= 0)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{processing
|
{processing
|
||||||
@ -578,7 +615,7 @@ export default function PurchaseEdit({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={
|
disabled={
|
||||||
item.quantity <= 0
|
(parseFloat(item.quantity) || 0) <= 0
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
item.onAdjust(-1)
|
item.onAdjust(-1)
|
||||||
@ -586,10 +623,12 @@ export default function PurchaseEdit({
|
|||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
className="w-20 text-center"
|
className="w-20 text-center"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onValueChange={item.onSet}
|
onChange={(e) => item.onSet(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -605,7 +644,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 * item.quantity,
|
item.price * (parseFloat(item.quantity) || 0),
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -45,6 +45,7 @@ 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;
|
||||||
@ -53,19 +54,19 @@ export type RestockForEdit = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductVariantForRestock = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
stock: number;
|
||||||
|
reject_stock: number;
|
||||||
|
photo_url: string | null;
|
||||||
|
capital_price: number;
|
||||||
|
reject_price: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductForRestock = {
|
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 = {
|
||||||
|
|||||||
@ -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, RestockCreateData } from './columns';
|
import type { ProductForRestock, ProductVariantForRestock, RestockCreateData } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -82,6 +82,8 @@ 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(
|
||||||
() => ({
|
() => ({
|
||||||
@ -112,16 +114,54 @@ export default function RestockCreate({ products }: Props) {
|
|||||||
[products, selectedProductId],
|
[products, selectedProductId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const variantById = useMemo(
|
const selectedProductVariants = useMemo(
|
||||||
() =>
|
() => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []),
|
||||||
new Map(
|
[selectedProduct, fetchedVariants],
|
||||||
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);
|
||||||
@ -294,7 +334,16 @@ return 0;
|
|||||||
|
|
||||||
{selectedProduct && (
|
{selectedProduct && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedProduct.product_variants.map(
|
{loadingVariants ? (
|
||||||
|
<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'
|
||||||
@ -410,7 +459,7 @@ return 0;
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -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 } from './columns';
|
import type { RestockCreateData, RestockForEdit, ProductVariantForRestock } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -49,16 +49,13 @@ 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 = useMemo(() => {
|
const [selectedProductId, setSelectedProductId] = useState(() => {
|
||||||
const items = restock.items ?? [];
|
const items = restock.items ?? [];
|
||||||
const product = products.find((p) =>
|
const product = products.find((p) =>
|
||||||
p.product_variants.some((v) =>
|
restock.existing_product_ids?.includes(p.id),
|
||||||
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) => [
|
||||||
@ -74,6 +71,8 @@ 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);
|
||||||
|
|
||||||
@ -86,25 +85,84 @@ export default function RestockEdit({ restock, products }: Props) {
|
|||||||
[products, selectedProductId],
|
[products, selectedProductId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const variantById = useMemo(
|
const selectedProductVariants = useMemo(
|
||||||
() =>
|
() => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []),
|
||||||
new Map(
|
[selectedProduct, fetchedVariants],
|
||||||
products.flatMap((p) =>
|
|
||||||
p.product_variants.map((v) => [v.id, v]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[products],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const productByVariantId = useMemo(
|
useEffect(() => {
|
||||||
() =>
|
if (!selectedProduct) return;
|
||||||
new Map(
|
if (fetchedVariants.has(selectedProduct.id)) return;
|
||||||
products.flatMap((p) =>
|
|
||||||
p.product_variants.map((v) => [v.id, p.name]),
|
setLoadingVariants(true);
|
||||||
),
|
fetch(`/admin/master/products/${selectedProduct.id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(selectedProduct.id, data.variants);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian produk.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [selectedProduct, fetchedVariants]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restock.existing_product_ids?.length) return;
|
||||||
|
|
||||||
|
const toFetch = restock.existing_product_ids.filter(
|
||||||
|
(id) => !fetchedVariants.has(id),
|
||||||
|
);
|
||||||
|
if (toFetch.length === 0) return;
|
||||||
|
|
||||||
|
setLoadingVariants(true);
|
||||||
|
Promise.all(
|
||||||
|
toFetch.map((id) =>
|
||||||
|
fetch(`/admin/master/products/${id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => ({ id, variants: data.variants })),
|
||||||
),
|
),
|
||||||
[products],
|
)
|
||||||
);
|
.then((results) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const { id, variants } of results) {
|
||||||
|
next.set(id, variants);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian produk.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [restock.existing_product_ids]);
|
||||||
|
|
||||||
|
const variantById = useMemo(() => {
|
||||||
|
const map = new Map<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) => {
|
||||||
@ -232,7 +290,16 @@ return 0;
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{selectedProduct ? (
|
{selectedProduct ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedProduct.product_variants.map(
|
{loadingVariants ? (
|
||||||
|
<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'
|
||||||
@ -348,13 +415,14 @@ return 0;
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
) : (
|
||||||
Tidak ada item.
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Tidak ada item.
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
<InputError message={errors.items} />
|
<InputError message={errors.items} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -98,21 +98,24 @@ 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: {
|
|
||||||
id: number;
|
export type ProductVariantForTransaction = {
|
||||||
name: string;
|
id: number;
|
||||||
stock: number;
|
product_id: number;
|
||||||
reject_stock: number;
|
name: string;
|
||||||
retail_stock: number;
|
stock: number;
|
||||||
photo_url: string | null;
|
reject_stock: number;
|
||||||
prices: Record<string, number>;
|
retail_stock: number;
|
||||||
}[];
|
photo_url: string | null;
|
||||||
|
photo_conversion_url: string | null;
|
||||||
|
prices: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomerForTransaction = {
|
export type CustomerForTransaction = {
|
||||||
|
|||||||
@ -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, TransactionCreateData } from './columns';
|
import type { ProductForTransaction, ProductVariantForTransaction, TransactionCreateData } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -119,6 +119,8 @@ 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);
|
||||||
|
|
||||||
@ -164,38 +166,64 @@ 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 products
|
return [{
|
||||||
.map((p) => ({
|
...selectedProduct,
|
||||||
...p,
|
product_variants: selectedProductVariants.filter((v) =>
|
||||||
product_variants: p.product_variants.filter((v) =>
|
v.name.toLowerCase().includes(search),
|
||||||
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 productByVariantId = useMemo(
|
const variantById = useMemo(() => {
|
||||||
() =>
|
const map = new Map<number, ProductVariantForTransaction>();
|
||||||
new Map(
|
for (const [, variants] of fetchedVariants) {
|
||||||
products.flatMap((p: ProductForTransaction) =>
|
for (const v of variants) {
|
||||||
p.product_variants.map((v) => [v.id, p.name]),
|
map.set(v.id, v);
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
[products],
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channel === 'tiktok') {
|
if (channel === 'tiktok') {
|
||||||
@ -508,7 +536,16 @@ export default function TransactionCreate({
|
|||||||
|
|
||||||
{!variantSearch && selectedProduct && (
|
{!variantSearch && selectedProduct && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedProduct.product_variants.map(
|
{loadingVariants ? (
|
||||||
|
<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'
|
||||||
@ -528,10 +565,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_url ? (
|
{variant.photo_conversion_url ?? variant.photo_url ? (
|
||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
variant.photo_url
|
variant.photo_conversion_url ?? variant.photo_url
|
||||||
}
|
}
|
||||||
alt={
|
alt={
|
||||||
variant.name
|
variant.name
|
||||||
@ -629,9 +666,10 @@ export default function TransactionCreate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 { TransactionCreateData, TransactionForEdit } from './columns';
|
import type { ProductVariantForTransaction, TransactionCreateData, TransactionForEdit } from './columns';
|
||||||
|
|
||||||
type CartLine = {
|
type CartLine = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -98,15 +98,11 @@ 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 items = transaction.items ?? [];
|
const firstProductId = transaction.existing_product_ids?.[0];
|
||||||
const product = products.find((p) =>
|
return firstProductId ? String(firstProductId) : '';
|
||||||
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(
|
||||||
@ -156,25 +152,77 @@ export default function TransactionEdit({
|
|||||||
[products, selectedProductId],
|
[products, selectedProductId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const variantById = useMemo(
|
useEffect(() => {
|
||||||
() =>
|
const productIds = transaction.existing_product_ids ?? [];
|
||||||
new Map(
|
if (productIds.length === 0) return;
|
||||||
products.flatMap((p) =>
|
|
||||||
p.product_variants.map((v) => [v.id, v]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[products],
|
|
||||||
);
|
|
||||||
|
|
||||||
const productByVariantId = useMemo(
|
const toFetch = productIds.filter((id) => !fetchedVariants.has(id));
|
||||||
() =>
|
if (toFetch.length === 0) return;
|
||||||
new Map(
|
|
||||||
products.flatMap((p) =>
|
setLoadingVariants(true);
|
||||||
p.product_variants.map((v) => [v.id, p.name]),
|
Promise.all(
|
||||||
),
|
toFetch.map((id) =>
|
||||||
|
fetch(`/admin/master/products/${id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => [id, data.variants] as const)
|
||||||
),
|
),
|
||||||
[products],
|
)
|
||||||
);
|
.then((results) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [id, variants] of results) {
|
||||||
|
next.set(id, variants);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian produk.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [transaction.existing_product_ids]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct) return;
|
||||||
|
if (fetchedVariants.has(selectedProduct.id)) return;
|
||||||
|
|
||||||
|
setLoadingVariants(true);
|
||||||
|
fetch(`/admin/master/products/${selectedProduct.id}/variants`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setFetchedVariants((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(selectedProduct.id, data.variants);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Gagal memuat varian produk.');
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingVariants(false));
|
||||||
|
}, [selectedProduct, fetchedVariants]);
|
||||||
|
|
||||||
|
const variantById = useMemo(() => {
|
||||||
|
const map = new Map<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';
|
||||||
|
|
||||||
@ -397,7 +445,16 @@ export default function TransactionEdit({
|
|||||||
|
|
||||||
{selectedProduct && (
|
{selectedProduct && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedProduct.product_variants.map(
|
{loadingVariants ? (
|
||||||
|
<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'
|
||||||
@ -518,9 +575,10 @@ export default function TransactionEdit({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:categories.view|categories.create|categories.update|categories.delete');
|
Route::resource('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');
|
||||||
@ -57,6 +58,7 @@
|
|||||||
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations')->middleware('permission:products.view_stock_mutations');
|
Route::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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user