669 lines
32 KiB
TypeScript
669 lines
32 KiB
TypeScript
/**
|
||
* 建立生產工單頁面
|
||
* 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量
|
||
*/
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
|
||
import { formatQuantity } from "@/lib/utils";
|
||
import { Button } from "@/Components/ui/button";
|
||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||
import { router, useForm, Head, Link } from "@inertiajs/react";
|
||
import { toast } from "sonner";
|
||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||
import { Input } from "@/Components/ui/input";
|
||
import { Label } from "@/Components/ui/label";
|
||
import { Textarea } from "@/Components/ui/textarea";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||
|
||
interface Product {
|
||
id: number;
|
||
name: string;
|
||
code: string;
|
||
base_unit?: { id: number; name: string } | null;
|
||
}
|
||
|
||
interface Warehouse {
|
||
id: number;
|
||
name: string;
|
||
}
|
||
|
||
|
||
|
||
interface InventoryOption {
|
||
id: number;
|
||
product_id: number;
|
||
product_name: string;
|
||
product_code: string;
|
||
warehouse_id: number;
|
||
warehouse_name: string;
|
||
batch_number: string;
|
||
box_number: string | null;
|
||
quantity: number;
|
||
arrival_date: string | null;
|
||
expiry_date: string | null;
|
||
unit_name: string | null;
|
||
base_unit_id?: number;
|
||
base_unit_name?: string;
|
||
large_unit_id?: number;
|
||
large_unit_name?: string;
|
||
conversion_rate?: number;
|
||
}
|
||
|
||
interface BomItem {
|
||
// 後端必填
|
||
inventory_id: string; // 所選庫存記錄 ID(特定批號)
|
||
quantity_used: string; // 轉換後的最終數量(基本單位)
|
||
unit_id: string; // 單位 ID(通常為基本單位 ID)
|
||
|
||
// UI 狀態
|
||
ui_warehouse_id: string; // 來源倉庫
|
||
ui_product_id: string; // 批號列表篩選
|
||
ui_input_quantity: string; // 使用者輸入數量
|
||
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
|
||
|
||
// UI 輔助 / 快取
|
||
ui_product_name?: string;
|
||
ui_batch_number?: string;
|
||
ui_available_qty?: number;
|
||
ui_expiry_date?: string;
|
||
ui_conversion_rate?: number;
|
||
ui_base_unit_name?: string;
|
||
ui_large_unit_name?: string;
|
||
ui_base_unit_id?: number;
|
||
ui_large_unit_id?: number;
|
||
}
|
||
|
||
interface Props {
|
||
products: Product[];
|
||
warehouses: Warehouse[];
|
||
}
|
||
|
||
export default function Create({ products, warehouses }: Props) {
|
||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||
// 快取對照表:product_id -> inventories across warehouses
|
||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||
|
||
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
||
|
||
// 多配方支援
|
||
const [recipes, setRecipes] = useState<any[]>([]);
|
||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||
|
||
const { data, setData, processing, errors } = useForm({
|
||
product_id: "",
|
||
warehouse_id: "",
|
||
output_quantity: "",
|
||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
||
remark: "",
|
||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||
});
|
||
|
||
// 獲取特定商品在各倉庫的庫存分佈
|
||
const fetchProductInventories = async (productId: string) => {
|
||
if (!productId) return;
|
||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
||
if (loadingProducts[productId]) return;
|
||
|
||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||
try {
|
||
const res = await fetch(route('api.production.products.inventories', productId));
|
||
const data = await res.json();
|
||
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
|
||
}
|
||
};
|
||
|
||
// 同步 warehouse_id 到 form data (Output)
|
||
useEffect(() => {
|
||
setData('warehouse_id', selectedWarehouse);
|
||
}, [selectedWarehouse]);
|
||
|
||
// 新增 BOM 項目
|
||
const addBomItem = () => {
|
||
setBomItems([...bomItems, {
|
||
inventory_id: "",
|
||
quantity_used: "",
|
||
unit_id: "",
|
||
ui_warehouse_id: "",
|
||
ui_product_id: "",
|
||
ui_input_quantity: "",
|
||
ui_selected_unit: 'base',
|
||
}]);
|
||
};
|
||
|
||
// 移除 BOM 項目
|
||
const removeBomItem = (index: number) => {
|
||
setBomItems(bomItems.filter((_, i) => i !== index));
|
||
};
|
||
|
||
// 更新 BOM 項目邏輯
|
||
const updateBomItem = (index: number, field: keyof BomItem, value: any) => {
|
||
const updated = [...bomItems];
|
||
const item = { ...updated[index], [field]: value };
|
||
|
||
// 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
|
||
if (field === 'ui_product_id') {
|
||
item.ui_warehouse_id = "";
|
||
item.inventory_id = "";
|
||
item.quantity_used = "";
|
||
item.unit_id = "";
|
||
item.ui_input_quantity = "";
|
||
item.ui_selected_unit = "base";
|
||
// 清除 cache 資訊
|
||
delete item.ui_product_name;
|
||
delete item.ui_batch_number;
|
||
delete item.ui_available_qty;
|
||
delete item.ui_expiry_date;
|
||
delete item.ui_conversion_rate;
|
||
delete item.ui_base_unit_name;
|
||
delete item.ui_large_unit_name;
|
||
delete item.ui_base_unit_id;
|
||
delete item.ui_large_unit_id;
|
||
|
||
if (value) {
|
||
const prod = products.find(p => String(p.id) === value);
|
||
if (prod) {
|
||
item.ui_product_name = prod.name;
|
||
item.ui_base_unit_name = prod.base_unit?.name || '';
|
||
}
|
||
fetchProductInventories(value);
|
||
}
|
||
}
|
||
|
||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
|
||
if (field === 'ui_warehouse_id') {
|
||
item.inventory_id = "";
|
||
// 不重置數量
|
||
// item.quantity_used = "";
|
||
// item.ui_input_quantity = "";
|
||
// item.ui_selected_unit = "base";
|
||
|
||
// 清除某些 cache
|
||
delete item.ui_batch_number;
|
||
delete item.ui_available_qty;
|
||
delete item.ui_expiry_date;
|
||
}
|
||
|
||
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
||
if (field === 'inventory_id' && value) {
|
||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||
const inv = currentOptions.find(i => String(i.id) === value);
|
||
if (inv) {
|
||
item.ui_warehouse_id = String(inv.warehouse_id);
|
||
item.ui_batch_number = inv.batch_number;
|
||
item.ui_available_qty = inv.quantity;
|
||
item.ui_expiry_date = inv.expiry_date || '';
|
||
|
||
// 單位與轉換率
|
||
item.ui_base_unit_name = inv.unit_name || '';
|
||
item.ui_base_unit_id = inv.base_unit_id;
|
||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||
|
||
// 預設單位
|
||
item.ui_selected_unit = 'base';
|
||
item.unit_id = String(inv.base_unit_id || '');
|
||
|
||
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
|
||
if (!item.ui_input_quantity) {
|
||
item.ui_input_quantity = formatQuantity(inv.quantity);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 計算最終數量 (Base Quantity)
|
||
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||
const rate = item.ui_conversion_rate || 1;
|
||
|
||
if (item.ui_selected_unit === 'large') {
|
||
item.quantity_used = String(inputQty * rate);
|
||
item.unit_id = String(item.ui_base_unit_id || '');
|
||
} else {
|
||
item.quantity_used = String(inputQty);
|
||
item.unit_id = String(item.ui_base_unit_id || '');
|
||
}
|
||
}
|
||
|
||
updated[index] = item;
|
||
setBomItems(updated);
|
||
};
|
||
|
||
// 同步 BOM items 到表單 data
|
||
useEffect(() => {
|
||
setData('items', bomItems.map(item => ({
|
||
inventory_id: Number(item.inventory_id),
|
||
quantity_used: Number(item.quantity_used),
|
||
unit_id: item.unit_id ? Number(item.unit_id) : null
|
||
})));
|
||
}, [bomItems]);
|
||
|
||
// 應用配方到表單 (獨立函式)
|
||
const applyRecipe = (recipe: any) => {
|
||
if (!recipe || !recipe.items) return;
|
||
|
||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||
// 自動帶入配方標準產量
|
||
setData('output_quantity', String(yieldQty));
|
||
|
||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||
const baseQty = parseFloat(item.quantity || "0");
|
||
const calculatedQty = baseQty; // 保持精度
|
||
|
||
// 若有配方商品,預先載入庫存分佈
|
||
if (item.product_id) {
|
||
fetchProductInventories(String(item.product_id));
|
||
}
|
||
|
||
return {
|
||
inventory_id: "",
|
||
quantity_used: String(calculatedQty),
|
||
unit_id: String(item.unit_id),
|
||
ui_warehouse_id: "",
|
||
ui_product_id: String(item.product_id),
|
||
ui_product_name: item.product_name,
|
||
ui_batch_number: "",
|
||
ui_available_qty: 0,
|
||
ui_input_quantity: String(calculatedQty),
|
||
ui_selected_unit: 'base',
|
||
ui_base_unit_name: item.unit_name,
|
||
ui_base_unit_id: item.unit_id,
|
||
ui_conversion_rate: 1,
|
||
};
|
||
});
|
||
setBomItems(newBomItems);
|
||
|
||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||
description: `標準產量: ${yieldQty} 份`
|
||
});
|
||
};
|
||
|
||
// 當手動切換配方時
|
||
useEffect(() => {
|
||
if (!selectedRecipeId) return;
|
||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||
if (targetRecipe) {
|
||
applyRecipe(targetRecipe);
|
||
}
|
||
}, [selectedRecipeId]);
|
||
|
||
// 自動產生成品批號與載入配方
|
||
useEffect(() => {
|
||
if (!data.product_id) return;
|
||
|
||
// 2. 自動載入配方列表
|
||
const fetchRecipes = async () => {
|
||
try {
|
||
// 改為抓取所有配方
|
||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||
const recipesData = await res.json();
|
||
|
||
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||
setRecipes(recipesData);
|
||
// 預設選取最新的 (第一個)
|
||
const latest = recipesData[0];
|
||
setSelectedRecipeId(String(latest.id));
|
||
} else {
|
||
// 若無配方
|
||
setRecipes([]);
|
||
setSelectedRecipeId("");
|
||
setBomItems([]); // 清空 BOM
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to fetch recipes", e);
|
||
setRecipes([]);
|
||
setBomItems([]);
|
||
}
|
||
};
|
||
fetchRecipes();
|
||
}, [data.product_id]);
|
||
|
||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
||
useEffect(() => {
|
||
if (bomItems.length > 0 && data.output_quantity) {
|
||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
||
}
|
||
}, [data.output_quantity]);
|
||
|
||
// 提交表單
|
||
const submit = (status: 'draft' | 'completed') => {
|
||
// 驗證(簡單前端驗證,完整驗證在後端)
|
||
if (status === 'completed') {
|
||
const missingFields = [];
|
||
if (!data.product_id) missingFields.push('成品商品');
|
||
if (!data.output_quantity) missingFields.push('生產數量');
|
||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||
|
||
if (missingFields.length > 0) {
|
||
toast.error("請填寫必要欄位", {
|
||
description: `缺漏:${missingFields.join('、')}`
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 轉換 BOM items 格式
|
||
const formattedItems = bomItems
|
||
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
|
||
.map(item => ({
|
||
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
|
||
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
|
||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||
}));
|
||
|
||
// 使用 router.post 提交完整資料
|
||
router.post(route('production-orders.store'), {
|
||
...data,
|
||
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
||
items: formattedItems,
|
||
status: status,
|
||
}, {
|
||
onError: (errors) => {
|
||
const errorCount = Object.keys(errors).length;
|
||
toast.error("建立失敗,請檢查表單", {
|
||
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
submit('completed');
|
||
};
|
||
|
||
return (
|
||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||
<Head title="建立生產單" />
|
||
|
||
<div className="container mx-auto p-6 max-w-7xl">
|
||
<div className="mb-6">
|
||
<Link href={route('production-orders.index')}>
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 button-outlined-primary mb-6"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回生產單
|
||
</Button>
|
||
</Link>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||
<Factory className="h-6 w-6 text-primary-main" />
|
||
建立生產工單
|
||
</h1>
|
||
<p className="text-gray-500 mt-1">
|
||
建立新的生產排程,選擇原物料並記錄產出
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={() => submit('draft')}
|
||
disabled={processing}
|
||
className="gap-2 button-filled-primary"
|
||
>
|
||
<Save className="h-4 w-4" />
|
||
儲存工單 (草稿)
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{/* 成品資訊 */}
|
||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||
<h2 className="text-lg font-semibold mb-4">成品資訊</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs font-medium text-grey-2">成品商品 *</Label>
|
||
<SearchableSelect
|
||
value={data.product_id}
|
||
onValueChange={(v) => setData('product_id', v)}
|
||
options={products.map(p => ({
|
||
label: `${p.name} (${p.code})`,
|
||
value: String(p.id),
|
||
}))}
|
||
placeholder="選擇成品"
|
||
className="w-full h-9"
|
||
/>
|
||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||
|
||
{/* 配方選擇 (放在成品商品底下) */}
|
||
{recipes.length > 0 && (
|
||
<div className="pt-2">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<Label className="text-xs font-medium text-grey-2">使用配方</Label>
|
||
<span className="text-[10px] text-blue-500">
|
||
切換將重置明細
|
||
</span>
|
||
</div>
|
||
<SearchableSelect
|
||
value={selectedRecipeId}
|
||
onValueChange={setSelectedRecipeId}
|
||
options={recipes.map(r => ({
|
||
label: `${r.name} (${r.code})`,
|
||
value: String(r.id),
|
||
}))}
|
||
placeholder="選擇配方"
|
||
className="w-full h-9"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
|
||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||
placeholder="例如: 50"
|
||
className="h-9 font-mono"
|
||
/>
|
||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs font-medium text-grey-2">預計入庫倉庫 *</Label>
|
||
<SearchableSelect
|
||
value={selectedWarehouse}
|
||
onValueChange={setSelectedWarehouse}
|
||
options={warehouses.map(w => ({
|
||
label: w.name,
|
||
value: String(w.id),
|
||
}))}
|
||
placeholder="選擇倉庫"
|
||
className="w-full h-9"
|
||
/>
|
||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-1">
|
||
<Label className="text-xs font-medium text-grey-2">備註</Label>
|
||
<Textarea
|
||
value={data.remark}
|
||
onChange={(e) => setData('remark', e.target.value)}
|
||
placeholder="生產備註..."
|
||
rows={2}
|
||
className="resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* BOM 原物料明細 */}
|
||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold">原物料使用明細 (BOM)</h2>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={addBomItem}
|
||
className="gap-2 button-filled-primary text-white"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
新增原物料
|
||
</Button>
|
||
</div>
|
||
|
||
{bomItems.length === 0 && (
|
||
<div className="text-center py-8 text-gray-500">
|
||
<Factory className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||
點擊「新增原物料」開始建立 BOM
|
||
</div>
|
||
)}
|
||
|
||
{bomItems.length > 0 && (
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-gray-50/50">
|
||
<TableHead className="w-[18%]">商品 <span className="text-red-500">*</span></TableHead>
|
||
<TableHead className="w-[15%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||
<TableHead className="w-[30%]">批號 <span className="text-red-500">*</span></TableHead>
|
||
<TableHead className="w-[12%]">數量 <span className="text-red-500">*</span></TableHead>
|
||
<TableHead className="w-[10%]">單位</TableHead>
|
||
<TableHead className="w-[10%]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{bomItems.map((item, index) => {
|
||
// 1. 商品選項
|
||
const productOptions = products.map(p => ({
|
||
label: `${p.name} (${p.code})`,
|
||
value: String(p.id)
|
||
}));
|
||
|
||
// 2. 來源倉庫選項 (根據商品庫存過濾)
|
||
const currentInventories = productInventoryMap[item.ui_product_id] || [];
|
||
const filteredWarehouseOptions = Array.from(new Map(
|
||
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
|
||
).values());
|
||
|
||
// 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
|
||
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||
? filteredWarehouseOptions
|
||
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||
|
||
// 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔)
|
||
const batchOptions = currentInventories
|
||
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
|
||
.map((inv: InventoryOption) => ({
|
||
label: inv.batch_number,
|
||
value: String(inv.id),
|
||
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
|
||
}));
|
||
|
||
return (
|
||
<TableRow key={index}>
|
||
{/* 1. 選擇商品 */}
|
||
<TableCell className="align-top">
|
||
<SearchableSelect
|
||
value={item.ui_product_id}
|
||
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
||
options={productOptions}
|
||
placeholder="選擇商品"
|
||
className="w-full"
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* 2. 選擇來源倉庫 */}
|
||
<TableCell className="align-top">
|
||
<SearchableSelect
|
||
value={item.ui_warehouse_id}
|
||
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
||
options={uniqueWarehouseOptions as any}
|
||
placeholder={item.ui_product_id
|
||
? (loadingProducts[item.ui_product_id]
|
||
? "載入庫存中..."
|
||
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
|
||
: "請先選商品"}
|
||
className="w-full"
|
||
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* 3. 選擇批號 */}
|
||
<TableCell className="align-top">
|
||
<SearchableSelect
|
||
value={item.inventory_id}
|
||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||
options={batchOptions as any}
|
||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||
className="w-full"
|
||
disabled={!item.ui_warehouse_id}
|
||
/>
|
||
{item.inventory_id && (() => {
|
||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||
if (selectedInv) {
|
||
const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
|
||
return (
|
||
<div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
|
||
有效日期: {selectedInv.expiry_date || '無'} |
|
||
庫存: {formatQuantity(selectedInv.quantity)}
|
||
{isInsufficient && ' (庫存不足!)'}
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
</TableCell>
|
||
|
||
{/* 3. 輸入數量 */}
|
||
<TableCell className="align-top">
|
||
<Input
|
||
type="number"
|
||
step="any"
|
||
value={formatQuantity(item.ui_input_quantity)}
|
||
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
||
placeholder="0"
|
||
className="h-9 text-right"
|
||
disabled={!item.inventory_id}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* 4. 單位 */}
|
||
<TableCell className="align-top">
|
||
<div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
|
||
{item.ui_base_unit_name || '-'}
|
||
</div>
|
||
</TableCell>
|
||
|
||
|
||
<TableCell className="align-top">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => removeBomItem(index)}
|
||
className="button-outlined-error"
|
||
title="刪除"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
|
||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</AuthenticatedLayout>
|
||
);
|
||
}
|