更新:優化配方詳情彈窗 UI 與一般修正
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user