"use client"; import React, { useEffect, useRef, useState } from "react"; import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Paper, Table, TableHead, TableRow, TableCell, TableBody, Button, TextField, Checkbox, FormControlLabel, IconButton, } from "@mui/material"; import type { BomCombo, BomDetailResponse } from "@/app/api/bom"; import { editBomClient, fetchBomComboClient, fetchBomDetailClient, fetchAllEquipmentsMasterClient, fetchAllProcessesMasterClient, type EquipmentMasterRow, type ProcessMasterRow, } from "@/app/api/bom/client"; import { useTranslation } from "react-i18next"; import SearchBox, { Criterion } from "../SearchBox"; import { useMemo, useCallback } from "react"; import AddIcon from "@mui/icons-material/Add"; import SaveIcon from "@mui/icons-material/Save"; import CancelIcon from "@mui/icons-material/Cancel"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; /** 以 description + "-" + name 對應 code,或同一筆設備的 description+name。 */ function resolveEquipmentCode( list: EquipmentMasterRow[], description: string, name: string, ): string | null { const d = description.trim(); const n = name.trim(); if (!d && !n) return null; if (!d || !n) return null; const composite = `${d}-${n}`; const byCode = list.find((e) => e.code === composite); if (byCode) return byCode.code; const byPair = list.find( (e) => e.description === d && e.name === n, ); return byPair?.code ?? null; } const ImportBomDetailTab: React.FC = () => { const { t } = useTranslation( "common" ); const [bomList, setBomList] = useState([]); const [selectedBomId, setSelectedBomId] = useState(""); const [detail, setDetail] = useState(null); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [filteredBoms, setFilteredBoms] = useState([]) const [currentBom, setCurrentBom] = useState(null); const loadDetailInFlightRef = useRef(false); type EditMaterialRow = { key: string; id?: number; itemCode?: string; itemName?: string; qty: number; isConsumable: boolean; baseUom?: string; stockQty?: number; stockUom?: string; salesQty?: number; salesUom?: string; }; type EditProcessRow = { key: string; id?: number; seqNo?: number; processCode?: string; processName?: string; description: string; /** 設備主檔 description(下拉),與 equipmentName 一併解析為 equipment.code */ equipmentDescription: string; equipmentName: string; durationInMinute: number; prepTimeInMinute: number; postProdTimeInMinute: number; }; const [isEditing, setIsEditing] = useState(false); const [editLoading, setEditLoading] = useState(false); const [editError, setEditError] = useState(null); const [editBasic, setEditBasic] = useState<{ description: string; outputQty: number; outputQtyUom: string; isDark: number; isFloat: number; isDense: number; scrapRate: number; allergicSubstances: number; timeSequence: number; complexity: number; isDrink: boolean; } | null>(null); const [editMaterials, setEditMaterials] = useState([]); const [editProcesses, setEditProcesses] = useState([]); const [equipmentMasterList, setEquipmentMasterList] = useState< EquipmentMasterRow[] >([]); const [processMasterList, setProcessMasterList] = useState< ProcessMasterRow[] >([]); const [editMasterLoading, setEditMasterLoading] = useState(false); // Process add form (uses dropdown selections from master tables). const [processAddForm, setProcessAddForm] = useState<{ processCode: string; equipmentDescription: string; equipmentName: string; description: string; durationInMinute: number; prepTimeInMinute: number; postProdTimeInMinute: number; }>({ processCode: "", equipmentDescription: "", equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }); const processCodeOptions = useMemo(() => { const codes = new Set(); processMasterList.forEach((p) => { if (p.code) codes.add(p.code); }); return Array.from(codes).sort(); }, [processMasterList]); const equipmentDescriptionOptions = useMemo(() => { const s = new Set(); equipmentMasterList.forEach((e) => { if (e.description) s.add(e.description); }); return Array.from(s).sort(); }, [equipmentMasterList]); const equipmentNameOptions = useMemo(() => { const s = new Set(); equipmentMasterList.forEach((e) => { if (e.name) s.add(e.name); }); return Array.from(s).sort(); }, [equipmentMasterList]); useEffect(() => { const loadList = async () => { setLoadingList(true); try { const list = await fetchBomComboClient(); setBomList(list); } finally { setLoadingList(false); } }; loadList(); }, []); type BomSearchKey = "code" | "name"; const searchCriteria: Criterion[] = useMemo( () => [ { label: t("Code"), paramName: "code", type: "text" }, { label: t("Name"), paramName: "name", type: "text" }, ], [t], ); useEffect(() => { setFilteredBoms([]); }, [bomList]); const loadBomDetail = useCallback( async (id: number) => { if (!id || loadDetailInFlightRef.current) return; loadDetailInFlightRef.current = true; setSelectedBomId(id); setCurrentBom(bomList.find((b) => b.id === id) ?? null); setDetail(null); setLoadingDetail(true); try { const d = await fetchBomDetailClient(id); setDetail(d); } finally { setLoadingDetail(false); loadDetailInFlightRef.current = false; } }, [bomList], ); const handleSearchBom = useCallback( (inputs: Record) => { const code = (inputs.code ?? "").trim().toLowerCase(); const name = (inputs.name ?? "").trim().toLowerCase(); const result = bomList.filter((b) => { const label = String(b.label ?? "").toLowerCase(); const okCode = !code || label.includes(code); const okName = !name || label.includes(name); return okCode && okName; }); setFilteredBoms(result); // 如果只找到一個,直接載入明細 if (result.length === 1) { void loadBomDetail(result[0].id); } else { setSelectedBomId(""); setCurrentBom(null); setDetail(null); } }, [bomList, loadBomDetail], ); const renderAllergic = (v?: number) => { if (v === 0) return "有"; if (v === 5) return "沒有"; return "-"; }; const renderIsFloat = (v?: number) => { if (v === 5) return "沉"; if (v === 3) return "浮"; if (v === 0) return "不適用"; return "-"; }; const renderIsDense = (v?: number) => { if (v === 5) return "淡"; if (v === 3) return "濃"; if (v === 0) return "不適用"; return "-"; }; const renderTimeSequence = (v?: number) => { if (v === 5) return "上午"; if (v === 1) return "下午"; if (v === 0) return "不適用"; return "-"; }; const renderType = (v?: string) => { if (v === "FG") return "成品"; if (v === "WIP") return "半成品"; return "-"; }; const renderComplexity = (v?: number) => { if (v === 10) return "簡單"; if (v === 5) return "中度"; if (v === 3) return "複雜"; if (v === 0) return "不適用"; return "-"; }; /* const handleResetBom = useCallback(() => { setFilteredBoms(bomList); setSelectedBomId(""); setDetail(null); }, [bomList]); */ const genKey = () => Math.random().toString(36).slice(2); const startEdit = useCallback(async () => { if (!detail) return; setEditError(null); setEditMasterLoading(true); try { const [equipments, processes] = await Promise.all([ fetchAllEquipmentsMasterClient(), fetchAllProcessesMasterClient(), ]); setEquipmentMasterList(equipments); setProcessMasterList(processes); setEditBasic({ description: detail.description ?? "", outputQty: detail.outputQty ?? 0, outputQtyUom: detail.outputQtyUom ?? "", isDark: detail.isDark ?? 0, isFloat: detail.isFloat ?? 0, isDense: detail.isDense ?? 0, scrapRate: detail.scrapRate ?? 0, allergicSubstances: detail.allergicSubstances ?? 0, timeSequence: detail.timeSequence ?? 0, complexity: detail.complexity ?? 0, isDrink: detail.isDrink ?? false, }); setEditMaterials( (detail.materials ?? []).map((m) => ({ key: genKey(), id: undefined, itemCode: m.itemCode ?? "", itemName: m.itemName ?? "", qty: m.baseQty ?? 0, isConsumable: m.isConsumable ?? false, baseUom: m.baseUom, stockQty: m.stockQty, stockUom: m.stockUom, salesQty: m.salesQty, salesUom: m.salesUom, })), ); setEditProcesses( (detail.processes ?? []).map((p) => { const code = (p.equipmentCode ?? "").trim(); const eq = code ? equipments.find((e) => e.code === code) : undefined; return { key: genKey(), id: undefined, seqNo: p.seqNo, processCode: p.processCode ?? "", processName: p.processName, description: p.processDescription ?? "", equipmentDescription: eq?.description ?? "", equipmentName: eq?.name ?? "", durationInMinute: p.durationInMinute ?? 0, prepTimeInMinute: p.prepTimeInMinute ?? 0, postProdTimeInMinute: p.postProdTimeInMinute ?? 0, }; }), ); setIsEditing(true); } catch (e: unknown) { const msg = e && typeof e === "object" && "message" in e ? String((e as { message?: string }).message) : "載入製程/設備主檔失敗"; setEditError(msg); } finally { setEditMasterLoading(false); } }, [detail]); const cancelEdit = useCallback(() => { setIsEditing(false); setEditLoading(false); setEditError(null); setEditBasic(null); setEditMaterials([]); setEditProcesses([]); setProcessAddForm({ processCode: "", equipmentDescription: "", equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }); setEquipmentMasterList([]); setProcessMasterList([]); }, []); const addMaterialRow = useCallback(() => { setEditMaterials((prev) => [ ...prev, { key: genKey(), itemCode: "", itemName: "", qty: 0, isConsumable: false, baseUom: "", stockQty: undefined, stockUom: "", salesQty: undefined, salesUom: "", }, ]); }, []); const addProcessRow = useCallback(() => { setEditProcesses((prev) => [ ...prev, { key: genKey(), seqNo: undefined, processCode: "", processName: "", description: "", equipmentDescription: "", equipmentName: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }, ]); }, []); const addProcessFromForm = useCallback(() => { const pCode = processAddForm.processCode.trim(); if (!pCode) { setEditError("請先選擇工序 Process Code"); return; } const ed = processAddForm.equipmentDescription.trim(); const en = processAddForm.equipmentName.trim(); if ((ed && !en) || (!ed && en)) { setEditError("設備描述與名稱需同時選取,或同時留空(不適用)"); return; } if (ed && en) { const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); if (!resolved) { setEditError( `設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, ); return; } } setEditProcesses((prev) => [ ...prev, { key: genKey(), seqNo: undefined, processCode: pCode, processName: "", description: processAddForm.description ?? "", equipmentDescription: ed, equipmentName: en, durationInMinute: processAddForm.durationInMinute ?? 0, prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, }, ]); setProcessAddForm({ processCode: "", equipmentDescription: "", equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }); setEditError(null); }, [processAddForm, equipmentMasterList]); const deleteMaterialRow = useCallback((key: string) => { setEditMaterials((prev) => prev.filter((r) => r.key !== key)); }, []); const deleteProcessRow = useCallback((key: string) => { setEditProcesses((prev) => prev.filter((r) => r.key !== key)); }, []); const handleSaveEdit = useCallback(async () => { if (!detail || !editBasic) return; setEditLoading(true); setEditError(null); try { for (const p of editProcesses) { if (!p.processCode?.trim()) { throw new Error("工序行 Process Code 不能为空"); } const ed = p.equipmentDescription.trim(); const en = p.equipmentName.trim(); if ((ed && !en) || (!ed && en)) { throw new Error("各製程行的設備描述與名稱需同時填寫或同時留空"); } if (ed && en) { const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); if (!resolved) { throw new Error( `設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`, ); } } } const payload: any = { description: editBasic.description || undefined, outputQty: editBasic.outputQty, outputQtyUom: editBasic.outputQtyUom || undefined, isDark: editBasic.isDark, isFloat: editBasic.isFloat, isDense: editBasic.isDense, scrapRate: editBasic.scrapRate, allergicSubstances: editBasic.allergicSubstances, timeSequence: editBasic.timeSequence, complexity: editBasic.complexity, isDrink: editBasic.isDrink, processes: editProcesses.map((p) => { const ed = p.equipmentDescription.trim(); const en = p.equipmentName.trim(); const equipmentCode = ed && en ? resolveEquipmentCode(equipmentMasterList, ed, en) ?? undefined : undefined; return { id: p.id, seqNo: p.seqNo, processCode: p.processCode?.trim() || undefined, equipmentCode, description: p.description || undefined, durationInMinute: p.durationInMinute, prepTimeInMinute: p.prepTimeInMinute, postProdTimeInMinute: p.postProdTimeInMinute, }; }), }; const updated = await editBomClient(detail.id, payload); setDetail(updated); setIsEditing(false); } catch (e: any) { setEditError(e?.message || "保存失败"); } finally { setEditLoading(false); } }, [detail, editBasic, editProcesses, equipmentMasterList]); return ( criteria={searchCriteria} onSearch={handleSearchBom} //onReset={handleResetBom} /> {filteredBoms.length > 1 && ( 找到多筆 BOM,請選擇一筆載入明細 {filteredBoms.map((b) => ( ))} )} {loadingDetail && ( {t("Loading BOM Detail...")} )} {detail && ( {detail.itemCode} {detail.itemName} {/* Basic Info 列表 */} {t("Basic Info")} {!isEditing ? ( ) : ( )} {editError && ( {editError} )} {!isEditing && ( {/* 第一行:輸出數量 + 類型 */} {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} {" "} {t("Type")}: {detail.description ?? "-"} {/* 第二行:各種指標,排成一行 key:value, key:value */} {t("Allergic Substances")}:{" "} {renderAllergic(detail.allergicSubstances)} {" "}{t("Depth")}: {detail.isDark ?? "-"} {" "}{t("Float")}: {renderIsFloat(detail.isFloat)} {" "}{t("Density")}: {renderIsDense(detail.isDense)} {t("Time Sequence")}: {renderTimeSequence(detail.timeSequence)} {" "}{t("Complexity")}: {renderComplexity(detail.complexity)} {" "}{t("Base Score")}: {detail.baseScore ?? "-"} )} {isEditing && editBasic && ( setEditBasic((p) => (p ? { ...p, description: e.target.value } : p)) } fullWidth /> setEditBasic((p) => p ? { ...p, outputQty: Number(e.target.value) } : p ) } /> setEditBasic((p) => p ? { ...p, outputQtyUom: e.target.value } : p ) } /> setEditBasic((p) => p ? { ...p, scrapRate: Number(e.target.value) } : p ) } /> {t("Allergic Substances")} setEditBasic((p) => p ? { ...p, isDark: Number(e.target.value) } : p ) } inputProps={{ min: 1, max: 5, step: 1 }} /> {t("Float")} {t("Density")} {t("Time Sequence")} {t("Complexity")} setEditBasic((p) => p ? { ...p, isDrink: e.target.checked } : p ) } /> } label={t("Is Drink")} /> )} {/* 材料列表 */} {t("Bom Material")} {t("Item Code")} {t("Item Name")} {t("Base Qty")} {t("Base UOM")} {t("Stock Qty")} {t("Stock UOM")} {t("Sales Qty")} {t("Sales UOM")} {detail.materials.map((m, i) => ( {m.itemCode} {m.itemName} {m.baseQty} {m.baseUom} {m.stockQty} {m.stockUom} {m.salesQty} {m.salesUom} ))}
{/* 製程 + 設備列表 */} {t("Process & Equipment")} {isEditing && ( {t("Process Code")} 設備說明 設備名稱 setProcessAddForm((p) => ({ ...p, description: e.target.value, })) } /> setProcessAddForm((p) => ({ ...p, durationInMinute: Number(e.target.value), })) } /> setProcessAddForm((p) => ({ ...p, prepTimeInMinute: Number(e.target.value), })) } /> setProcessAddForm((p) => ({ ...p, postProdTimeInMinute: Number(e.target.value), })) } /> )} {t("Sequence")} {t("Process Name")} {t("Process Description")} {t("Process Code")} 設備(說明/名稱) {t("Duration (Minutes)")} {t("Prep Time (Minutes)")} {t("Post Prod Time (Minutes)")} {isEditing && ( {t("Actions")} )} {isEditing ? editProcesses.map((p) => ( {p.seqNo ?? "-"} {p.processName || "-"} setEditProcesses((prev) => prev.map((x) => x.key === p.key ? { ...x, description: e.target.value } : x, ), ) } /> setEditProcesses((prev) => prev.map((x) => x.key === p.key ? { ...x, durationInMinute: Number(e.target.value) } : x, ), ) } /> setEditProcesses((prev) => prev.map((x) => x.key === p.key ? { ...x, prepTimeInMinute: Number(e.target.value) } : x, ), ) } /> setEditProcesses((prev) => prev.map((x) => x.key === p.key ? { ...x, postProdTimeInMinute: Number(e.target.value), } : x, ), ) } /> deleteProcessRow(p.key)}> )) : detail.processes.map((p, i) => ( {p.seqNo} {p.processName} {p.processDescription} {p.processCode ?? "-"} {p.equipmentCode ?? p.equipmentName} {p.durationInMinute} {p.prepTimeInMinute} {p.postProdTimeInMinute} ))}
)}
); }; export default ImportBomDetailTab;