/** * 建立生產工單頁面 * 動態 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; purchase_unit_id?: number; conversion_rate?: number; unit_cost?: 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; ui_purchase_unit_id?: number; ui_unit_cost?: number; } interface Props { products: Product[]; warehouses: Warehouse[]; } export default function Create({ products, warehouses }: Props) { const [selectedWarehouse, setSelectedWarehouse] = useState(""); // 產出倉庫 // 快取對照表:product_id -> inventories across warehouses const [productInventoryMap, setProductInventoryMap] = useState>({}); const [loadingProducts, setLoadingProducts] = useState>({}); const [bomItems, setBomItems] = useState([]); // 多配方支援 const [recipes, setRecipes] = useState([]); const [selectedRecipeId, setSelectedRecipeId] = useState(""); // 提交表單 const { data, setData, processing, errors, setError, clearErrors } = 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"; 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); } } if (field === 'ui_warehouse_id') { item.inventory_id = ""; delete item.ui_batch_number; delete item.ui_available_qty; delete item.ui_expiry_date; } 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_large_unit_id = inv.large_unit_id; item.ui_purchase_unit_id = inv.purchase_unit_id; item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_unit_cost = inv.unit_cost || 0; 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); } } } 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; item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty); item.unit_id = String(item.ui_base_unit_id || ''); } updated[index] = item; setBomItems(updated); }; 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', formatQuantity(yieldQty)); const newBomItems: BomItem[] = recipe.items.map((item: any) => { if (item.product_id) fetchProductInventories(String(item.product_id)); return { inventory_id: "", quantity_used: String(item.quantity || "0"), 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: formatQuantity(item.quantity || "0"), 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}`); }; useEffect(() => { if (!selectedRecipeId) return; const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); if (targetRecipe) applyRecipe(targetRecipe); }, [selectedRecipeId]); useEffect(() => { if (!data.product_id) return; 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); setSelectedRecipeId(String(recipesData[0].id)); } else { setRecipes([]); setSelectedRecipeId(""); setBomItems([]); } } catch (e) { setRecipes([]); setBomItems([]); } }; fetchRecipes(); }, [data.product_id]); // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位 useEffect(() => { const errorKeys = Object.keys(errors); if (errorKeys.length > 0) { // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染) setTimeout(() => { const firstInvalid = document.querySelector('[aria-invalid="true"]'); if (firstInvalid instanceof HTMLElement) { firstInvalid.focus(); firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); } }, [errors]); const submit = (status: 'draft') => { clearErrors(); let hasError = false; // 草稿建立時也要求必填生產數量與預計入庫倉庫 if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; } if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; } if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; } if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; } // 驗證 BOM 明細 bomItems.forEach((item, index) => { if (!item.ui_product_id) { setError(`items.${index}.ui_product_id` as any, '請選擇商品'); hasError = true; } else { if (!item.inventory_id) { setError(`items.${index}.inventory_id` as any, '請選擇批號'); hasError = true; } if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) { setError(`items.${index}.quantity_used` as any, '請輸入數量'); hasError = true; } } }); if (hasError) { toast.error("建立失敗,請檢查標單內紅框欄位"); return; } const formattedItems = bomItems.map(item => ({ inventory_id: parseInt(item.inventory_id), quantity_used: parseFloat(item.quantity_used), unit_id: item.unit_id ? parseInt(item.unit_id) : null, })); router.post(route('production-orders.store'), { ...data, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, items: formattedItems, status: status, }, { onError: () => { toast.error("建立失敗,請檢查表單"); } }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); submit('draft'); }; const getBomItemUnitCost = (item: BomItem) => { if (!item.ui_unit_cost) return 0; let cost = Number(item.ui_unit_cost); // Check if selected unit is large_unit or purchase_unit if (item.unit_id && (String(item.unit_id) === String(item.ui_large_unit_id) || String(item.unit_id) === String(item.ui_purchase_unit_id))) { cost = cost * Number(item.ui_conversion_rate || 1); } return cost; }; const totalEstimatedCost = bomItems.reduce((sum, item) => { if (!item.ui_input_quantity || !item.ui_unit_cost) return sum; const inputQty = parseFloat(item.ui_input_quantity || '0'); const unitCost = getBomItemUnitCost(item); return sum + (unitCost * inputQty); }, 0); return (

建立生產工單

建立新的生產排程,選擇原物料並記錄產出

{/* 成品資訊 */}

成品資訊

setData('product_id', v)} options={products.map(p => ({ label: `${p.name} (${p.code})`, value: String(p.id), }))} placeholder="選擇成品" className="w-full h-9" aria-invalid={!!errors.product_id} /> {errors.product_id &&

{errors.product_id}

} {/* 配方選擇 (放在成品商品底下) */} {recipes.length > 0 && (
切換將重置明細
({ label: `${r.name} (${r.code})`, value: String(r.id), }))} placeholder="選擇配方" className="w-full h-9" />
)}
setData('output_quantity', e.target.value)} placeholder="例如: 50" className="h-9 font-mono" aria-invalid={!!errors.output_quantity} /> {errors.output_quantity &&

{errors.output_quantity}

}
({ label: w.name, value: String(w.id), }))} placeholder="選擇倉庫" className="w-full h-9" aria-invalid={!!errors.warehouse_id} /> {errors.warehouse_id &&

{errors.warehouse_id}

}