更新:優化配方詳情彈窗 UI 與一般修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s

This commit is contained in:
2026-01-29 16:13:56 +08:00
parent 7619dc24f7
commit 746eeb6f01
23 changed files with 1925 additions and 79 deletions

View File

@@ -4,11 +4,11 @@
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
@@ -90,12 +90,17 @@ export default function ProductionCreate({ products, warehouses }: Props) {
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: "",
output_batch_number: "",
output_box_count: "",
// 移除 Box Count UI
// 移除相關邏輯
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "",
@@ -244,34 +249,116 @@ export default function ProductionCreate({ products, warehouses }: Props) {
})));
}, [bomItems]);
// 自動產生成品批號(當選擇商品或日期變動時)
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', String(yieldQty));
const ratio = 1;
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0");
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度
return {
inventory_id: "",
quantity_used: String(calculatedQty),
unit_id: String(item.unit_id),
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
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);
// 若有選倉庫,預先載入庫存資料以供選擇
if (selectedWarehouse) {
fetchWarehouseInventory(selectedWarehouse);
}
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;
// 1. 自動產生成品批號
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
if (product) {
const datePart = data.production_date;
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
const datePart = data.production_date; // YYYY-MM-DD
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}
// 呼叫 API 取得下一組流水號
// 複用庫存批號 API但這裡可能沒有選 warehouse所以用第一個預設
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
// 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json();
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
// Fallback若 API 失敗,使用預設 01
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}, [data.product_id, data.production_date]);
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') => {
@@ -286,12 +373,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm">{missingFields.join('、')}</span>
</div>
);
toast.error("請填寫必要欄位", {
description: `缺漏:${missingFields.join('、')}`
});
return;
}
}
@@ -313,12 +397,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm"> {errorCount} </span>
</div>
);
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
}
});
};
@@ -331,7 +412,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<Toaster position="top-right" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('production-orders.index')}>
@@ -394,6 +475,28 @@ export default function ProductionCreate({ products, warehouses }: Props) {
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">
@@ -420,15 +523,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>