Files
sky121113 6ca0bafd60 [FEAT] 新增生產工單實際產量欄位與 UI 規範
- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
2026-03-10 15:32:52 +08:00

700 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 建立生產工單頁面
* 動態 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<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, 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 (
<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-gray-900 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
onClick={() => submit('draft')}
disabled={processing}
className="button-filled-primary gap-2"
>
<Save className="h-4 w-4" />
(稿)
</Button>
</div>
</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"
aria-invalid={!!errors.product_id}
/>
{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"
aria-invalid={!!errors.output_quantity}
/>
{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"
aria-invalid={!!errors.warehouse_id}
/>
{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-[20%]"> <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%] text-right"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[5%]"></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: `(存:${formatQuantity(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"
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
/>
</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}
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
/>
{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}
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
/>
</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>
{/* 5. 預估單價 */}
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm text-gray-600 font-medium">
{getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
{/* 6. 成本小計 */}
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm font-bold text-gray-900">
{(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</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 className="mt-6 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">
{(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
</div>
</div>
</div>
</form>
</div>
</AuthenticatedLayout >
);
}