FPSMS-frontend
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

974 linhas
30 KiB

  1. "use client";
  2. import React, { useEffect, useRef, useState } from "react";
  3. import {
  4. Box,
  5. Stack,
  6. Typography,
  7. FormControl,
  8. InputLabel,
  9. Select,
  10. MenuItem,
  11. CircularProgress,
  12. Paper,
  13. Table,
  14. TableHead,
  15. TableRow,
  16. TableCell,
  17. TableBody,
  18. Button,
  19. TextField,
  20. Checkbox,
  21. FormControlLabel,
  22. IconButton,
  23. Grid,
  24. } from "@mui/material";
  25. import type { BomCombo, BomDetailResponse, BomStatus } from "@/app/api/bom";
  26. import {
  27. editBomClient,
  28. saveBomAsNewVersionClient,
  29. fetchBomComboClient,
  30. fetchBomVersionsClient,
  31. fetchBomDetailClient,
  32. fetchAllEquipmentsMasterClient,
  33. fetchAllProcessesMasterClient,
  34. downloadBomDetailExcelClient,
  35. type EquipmentMasterRow,
  36. type ProcessMasterRow,
  37. } from "@/app/api/bom/client";
  38. import { useTranslation } from "react-i18next";
  39. import SearchBox, { Criterion } from "../SearchBox";
  40. import { useMemo, useCallback } from "react";
  41. import AddIcon from "@mui/icons-material/Add";
  42. import SaveIcon from "@mui/icons-material/Save";
  43. import CancelIcon from "@mui/icons-material/Cancel";
  44. import DeleteIcon from "@mui/icons-material/Delete";
  45. import EditIcon from "@mui/icons-material/Edit";
  46. import { BomBasicInfoSection, type BomBasicInfoSectionHandle, saveBasicInfoDraft } from "./BomBasicInfoSection";
  47. import { BomVersionControlBar } from "./BomVersionControlBar";
  48. import {
  49. BomMaterialEditPanel,
  50. type BomMaterialEditPanelHandle,
  51. } from "./BomMaterialEditPanel";
  52. import {
  53. BomProcessEditPanel,
  54. type BomProcessEditPanelHandle,
  55. } from "./BomProcessEditPanel";
  56. import { getAmbiguousBomMatches, pickDefaultBom } from "./bomVersionUtils";
  57. import { materialVersionLinesFromDetail } from "./bomVersionSaveUtils";
  58. /** 以 description + "-" + name 對應 code,或同一筆設備的 description+name。 */
  59. function resolveEquipmentCode(
  60. list: EquipmentMasterRow[],
  61. description: string,
  62. name: string,
  63. ): string | null {
  64. const d = description.trim();
  65. const n = name.trim();
  66. if (!d && !n) return null;
  67. if (!d || !n) return null;
  68. const composite = `${d}-${n}`;
  69. const byCode = list.find((e) => e.code === composite);
  70. if (byCode) return byCode.code;
  71. const byPair = list.find(
  72. (e) => e.description === d && e.name === n,
  73. );
  74. return byPair?.code ?? null;
  75. }
  76. /** Full BOM field edit (materials/processes) — hidden until re-enabled. */
  77. const SHOW_BOM_FULL_EDIT = false;
  78. type BomSearchKey = "code" | "name";
  79. type BomSearchInputs = Record<BomSearchKey | `${BomSearchKey}To`, string>;
  80. const EMPTY_BOM_SEARCH_INPUTS: BomSearchInputs = {
  81. code: "",
  82. name: "",
  83. codeTo: "",
  84. nameTo: "",
  85. };
  86. /** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-16 */
  87. const ImportBomDetailTab: React.FC = () => {
  88. const { t } = useTranslation(["importBom", "common"]);
  89. const [bomList, setBomList] = useState<BomCombo[]>([]);
  90. const [selectedBomId, setSelectedBomId] = useState<number | "">("");
  91. const [detail, setDetail] = useState<BomDetailResponse | null>(null);
  92. const [loadingList, setLoadingList] = useState(false);
  93. const [versionsRefreshKey, setVersionsRefreshKey] = useState(0);
  94. const [filteredBoms, setFilteredBoms] = useState<BomCombo[]>([])
  95. const [currentBom, setCurrentBom] = useState<BomCombo | null>(null);
  96. const loadDetailInFlightRef = useRef(false);
  97. const saveInFlightRef = useRef(false);
  98. const exportInFlightRef = useRef(false);
  99. const skipAutoSearchRef = useRef(false);
  100. const detailIdRef = useRef<number | null>(null);
  101. const lastSearchInputsRef = useRef<BomSearchInputs>(EMPTY_BOM_SEARCH_INPUTS);
  102. type EditMaterialRow = {
  103. key: string;
  104. id?: number;
  105. itemCode?: string;
  106. itemName?: string;
  107. qty: number;
  108. isConsumable: boolean;
  109. baseUom?: string;
  110. stockQty?: number;
  111. stockUom?: string;
  112. salesQty?: number;
  113. salesUom?: string;
  114. };
  115. type EditProcessRow = {
  116. key: string;
  117. id?: number;
  118. seqNo?: number;
  119. processCode?: string;
  120. processName?: string;
  121. description: string;
  122. /** 設備主檔 description(下拉),與 equipmentName 一併解析為 equipment.code */
  123. equipmentDescription: string;
  124. equipmentName: string;
  125. durationInMinute: number;
  126. prepTimeInMinute: number;
  127. postProdTimeInMinute: number;
  128. };
  129. const [isEditing, setIsEditing] = useState(false);
  130. const [editLoading, setEditLoading] = useState(false);
  131. const [editError, setEditError] = useState<string | null>(null);
  132. const [editBasic, setEditBasic] = useState<{
  133. description: string;
  134. outputQty: number;
  135. outputQtyUom: string;
  136. isDark: number;
  137. isFloat: number;
  138. isDense: number;
  139. scrapRate: number;
  140. allergicSubstances: number;
  141. timeSequence: number;
  142. complexity: number;
  143. isDrink: boolean;
  144. isPowderMixture: boolean;
  145. } | null>(null);
  146. const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]);
  147. const [editProcesses, setEditProcesses] = useState<EditProcessRow[]>([]);
  148. const [equipmentMasterList, setEquipmentMasterList] = useState<
  149. EquipmentMasterRow[]
  150. >([]);
  151. const [processMasterList, setProcessMasterList] = useState<
  152. ProcessMasterRow[]
  153. >([]);
  154. const [editMasterLoading, setEditMasterLoading] = useState(false);
  155. const [saving, setSaving] = useState(false);
  156. const [exporting, setExporting] = useState(false);
  157. const [saveError, setSaveError] = useState<string | null>(null);
  158. const [versionId, setVersionId] = useState<number | "">("");
  159. const [compareMode, setCompareMode] = useState(false);
  160. const [compareToId, setCompareToId] = useState<number | "">("");
  161. const [compareOldDetail, setCompareOldDetail] = useState<BomDetailResponse | null>(null);
  162. const materialEditRef = useRef<BomMaterialEditPanelHandle>(null);
  163. const processEditRef = useRef<BomProcessEditPanelHandle>(null);
  164. const basicInfoRef = useRef<BomBasicInfoSectionHandle>(null);
  165. const [materialDirty, setMaterialDirty] = useState(false);
  166. const [processDirty, setProcessDirty] = useState(false);
  167. const [basicInfoDirty, setBasicInfoDirty] = useState(false);
  168. // Process add form (uses dropdown selections from master tables).
  169. const [processAddForm, setProcessAddForm] = useState<{
  170. processCode: string;
  171. equipmentDescription: string;
  172. equipmentName: string;
  173. description: string;
  174. durationInMinute: number;
  175. prepTimeInMinute: number;
  176. postProdTimeInMinute: number;
  177. }>({
  178. processCode: "",
  179. equipmentDescription: "",
  180. equipmentName: "",
  181. description: "",
  182. durationInMinute: 0,
  183. prepTimeInMinute: 0,
  184. postProdTimeInMinute: 0,
  185. });
  186. const processCodeOptions = useMemo(() => {
  187. const codes = new Set<string>();
  188. processMasterList.forEach((p) => {
  189. if (p.code) codes.add(p.code);
  190. });
  191. return Array.from(codes).sort();
  192. }, [processMasterList]);
  193. const equipmentDescriptionOptions = useMemo(() => {
  194. const s = new Set<string>();
  195. equipmentMasterList.forEach((e) => {
  196. if (e.description) s.add(e.description);
  197. });
  198. return Array.from(s).sort();
  199. }, [equipmentMasterList]);
  200. const equipmentNameOptions = useMemo(() => {
  201. const s = new Set<string>();
  202. equipmentMasterList.forEach((e) => {
  203. if (e.name) s.add(e.name);
  204. });
  205. return Array.from(s).sort();
  206. }, [equipmentMasterList]);
  207. useEffect(() => {
  208. const loadList = async () => {
  209. setLoadingList(true);
  210. try {
  211. const list = await fetchBomComboClient({ includeInactive: true });
  212. setBomList(list);
  213. } finally {
  214. setLoadingList(false);
  215. }
  216. };
  217. loadList();
  218. }, []);
  219. const searchCriteria: Criterion<BomSearchKey>[] = useMemo(
  220. () => [
  221. { label: t("Code"), paramName: "code", type: "text" },
  222. { label: t("Name"), paramName: "name", type: "text" },
  223. ],
  224. [t],
  225. );
  226. useEffect(() => {
  227. setFilteredBoms([]);
  228. }, [bomList]);
  229. useEffect(() => {
  230. detailIdRef.current = detail?.id ?? null;
  231. }, [detail?.id]);
  232. const recomputeFilteredBoms = useCallback(
  233. (list: BomCombo[], inputs: BomSearchInputs) => {
  234. const code = (inputs.code ?? "").trim().toLowerCase();
  235. const name = (inputs.name ?? "").trim().toLowerCase();
  236. const matched = list.filter((b) => {
  237. const label = String(b.label ?? "").toLowerCase();
  238. const okCode = !code || label.includes(code);
  239. const okName = !name || label.includes(name);
  240. return okCode && okName;
  241. });
  242. setFilteredBoms(getAmbiguousBomMatches(matched));
  243. return matched;
  244. },
  245. [],
  246. );
  247. const loadBomDetail = useCallback(
  248. async (id: number, options?: { resetVersionIds?: boolean }) => {
  249. if (!id || loadDetailInFlightRef.current) return;
  250. loadDetailInFlightRef.current = true;
  251. setSelectedBomId(id);
  252. setCurrentBom(bomList.find((b) => b.id === id) ?? null);
  253. try {
  254. const d = await fetchBomDetailClient(id);
  255. setDetail(d);
  256. setSaveError(null);
  257. if (options?.resetVersionIds !== false) {
  258. setVersionId(id);
  259. setCompareMode(false);
  260. setCompareToId(id);
  261. setCompareOldDetail(null);
  262. }
  263. } finally {
  264. loadDetailInFlightRef.current = false;
  265. }
  266. },
  267. [bomList],
  268. );
  269. useEffect(() => {
  270. if (
  271. !compareMode ||
  272. !compareToId ||
  273. !versionId ||
  274. compareToId === versionId
  275. ) {
  276. setCompareOldDetail(null);
  277. return;
  278. }
  279. let cancelled = false;
  280. void fetchBomDetailClient(compareToId).then((d) => {
  281. if (!cancelled) setCompareOldDetail(d);
  282. });
  283. return () => {
  284. cancelled = true;
  285. };
  286. }, [compareMode, compareToId, versionId]);
  287. const handleSearchBom = useCallback(
  288. (inputs: BomSearchInputs) => {
  289. lastSearchInputsRef.current = inputs;
  290. const code = (inputs.code ?? "").trim().toLowerCase();
  291. const name = (inputs.name ?? "").trim().toLowerCase();
  292. const matched = bomList.filter((b) => {
  293. const label = String(b.label ?? "").toLowerCase();
  294. const okCode = !code || label.includes(code);
  295. const okName = !name || label.includes(name);
  296. return okCode && okName;
  297. });
  298. const picked = pickDefaultBom(matched);
  299. setFilteredBoms(getAmbiguousBomMatches(matched));
  300. if (picked) {
  301. if (picked.id === detailIdRef.current) return;
  302. void loadBomDetail(picked.id);
  303. } else if (matched.length === 0) {
  304. setSelectedBomId("");
  305. setCurrentBom(null);
  306. setDetail(null);
  307. } else {
  308. setSelectedBomId("");
  309. setCurrentBom(null);
  310. setDetail(null);
  311. }
  312. },
  313. [bomList, loadBomDetail],
  314. );
  315. useEffect(() => {
  316. if (bomList.length === 0 || skipAutoSearchRef.current) return;
  317. const inputs = lastSearchInputsRef.current;
  318. const hasQuery = Boolean((inputs.code ?? "").trim() || (inputs.name ?? "").trim());
  319. if (!hasQuery) return;
  320. const code = (inputs.code ?? "").trim().toLowerCase();
  321. const name = (inputs.name ?? "").trim().toLowerCase();
  322. const matched = bomList.filter((b) => {
  323. const label = String(b.label ?? "").toLowerCase();
  324. const okCode = !code || label.includes(code);
  325. const okName = !name || label.includes(name);
  326. return okCode && okName;
  327. });
  328. const picked = pickDefaultBom(matched);
  329. setFilteredBoms(getAmbiguousBomMatches(matched));
  330. if (!picked || picked.id === detailIdRef.current) return;
  331. void loadBomDetail(picked.id);
  332. }, [bomList, loadBomDetail]);
  333. const renderBomStatus = (v?: BomStatus | string) => {
  334. if (v === "active") return t("BOM Status Active");
  335. if (v === "inactive") return t("BOM Status Inactive");
  336. return "-";
  337. };
  338. const isComparingVersions =
  339. compareMode &&
  340. Boolean(compareOldDetail) &&
  341. compareToId !== "" &&
  342. versionId !== "" &&
  343. compareToId !== versionId;
  344. const renderType = (v?: string) => {
  345. if (v === "FG") return "成品";
  346. if (v === "WIP") return "半成品";
  347. return "-";
  348. };
  349. const saveDisabled = !materialDirty && !basicInfoDirty && !processDirty;
  350. const unresolvedMaterialIssueCount = useMemo(
  351. () =>
  352. (detail?.materials ?? []).filter(
  353. (m) =>
  354. (m.baseQty == null || m.stockQty == null) &&
  355. (m.recipeQty ?? m.baseQty ?? 0) > 0,
  356. ).length,
  357. [detail?.materials],
  358. );
  359. const nonMaterialDirty = basicInfoDirty || processDirty;
  360. const blockedByUnresolvedMaterialIssue =
  361. unresolvedMaterialIssueCount > 0 && nonMaterialDirty && !materialDirty;
  362. const unresolvedMaterialIssueMessage =
  363. unresolvedMaterialIssueCount > 0
  364. ? t("bomSave_block_unresolvedMaterialIssue", {
  365. count: unresolvedMaterialIssueCount,
  366. })
  367. : null;
  368. const isFgDetail = useMemo(() => {
  369. if (!detail) return false;
  370. const kind = (detail.bomKind ?? detail.description ?? "FG").trim().toUpperCase();
  371. return kind !== "WIP";
  372. }, [detail]);
  373. const putawayLocationMissing = isFgDetail && !(detail?.putawayLocationCode ?? "").trim();
  374. const headerOutputQtyUnresolved = detail?.outputQtyStockConvertible === false;
  375. const putawayLocationMissingWarn = putawayLocationMissing
  376. ? t("bomPutawayLocation_missing_warn")
  377. : null;
  378. // Do not hard-disable Save purely from `detail` flags,
  379. // because user may still be editing draft values (putaway/header fix).
  380. // We enforce these blockers again in `handleSave()` using draft state.
  381. const effectiveSaveDisabled =
  382. saveDisabled || blockedByUnresolvedMaterialIssue;
  383. const refreshAfterSave = useCallback(async (saved: BomDetailResponse) => {
  384. const full = await fetchBomDetailClient(saved.id);
  385. detailIdRef.current = full.id;
  386. skipAutoSearchRef.current = true;
  387. setDetail(full);
  388. setVersionId(full.id);
  389. setCompareToId(full.id);
  390. setCompareMode(false);
  391. setCompareOldDetail(null);
  392. setVersionsRefreshKey((k) => k + 1);
  393. try {
  394. const list = await fetchBomComboClient({ includeInactive: true });
  395. setBomList(list);
  396. setCurrentBom(list.find((b) => b.id === full.id) ?? null);
  397. const inputs = lastSearchInputsRef.current;
  398. const hasQuery = Boolean(
  399. (inputs.code ?? "").trim() || (inputs.name ?? "").trim(),
  400. );
  401. if (hasQuery) {
  402. recomputeFilteredBoms(list, inputs);
  403. }
  404. } finally {
  405. skipAutoSearchRef.current = false;
  406. }
  407. }, [recomputeFilteredBoms]);
  408. const handleSave = useCallback(async () => {
  409. if (!detail?.id || saveInFlightRef.current) return;
  410. const materialPanel = materialEditRef.current;
  411. const processPanel = processEditRef.current;
  412. const basicPanel = basicInfoRef.current;
  413. const hasMaterialChanges = materialPanel?.hasMaterialChanges() ?? false;
  414. const hasProcessChanges = processPanel?.hasProcessChanges() ?? false;
  415. const hasBasicChanges = basicPanel?.hasBasicChanges() ?? false;
  416. const hasPutawayChanges = basicPanel?.hasPutawayChanges() ?? false;
  417. if (!hasMaterialChanges && !hasBasicChanges && !hasPutawayChanges && !hasProcessChanges) return;
  418. const putawayMissing = putawayLocationMissing && !hasPutawayChanges;
  419. if (putawayMissing) {
  420. setSaveError(t("bomPutawayLocation_missing_warn"));
  421. return;
  422. }
  423. const headerUnresolved = headerOutputQtyUnresolved;
  424. const headerDraft = basicPanel?.getDraft();
  425. const headerChangedInDraft =
  426. headerDraft != null &&
  427. (headerDraft.outputQty !== detail.outputQty ||
  428. (headerDraft.outputQtyUom ?? "") !== (detail.outputQtyUom ?? ""));
  429. if (headerUnresolved && !headerChangedInDraft) {
  430. setSaveError(t("bomHeaderOutputQtyStockConvertFail_warn"));
  431. return;
  432. }
  433. if (unresolvedMaterialIssueCount > 0 && !hasMaterialChanges && (hasBasicChanges || hasProcessChanges || hasPutawayChanges)) {
  434. setSaveError(
  435. t("bomSave_block_unresolvedMaterialIssue", {
  436. count: unresolvedMaterialIssueCount,
  437. }),
  438. );
  439. return;
  440. }
  441. const basicValidation = basicPanel?.validate() ?? null;
  442. if (basicValidation) {
  443. setSaveError(basicValidation);
  444. return;
  445. }
  446. const processValidation = (await processPanel?.validateAsync()) ?? null;
  447. if (processValidation) {
  448. setSaveError(processValidation);
  449. return;
  450. }
  451. saveInFlightRef.current = true;
  452. setSaving(true);
  453. setSaveError(null);
  454. try {
  455. let currentDetail = detail;
  456. let sourceDetail = detail;
  457. if (hasMaterialChanges || hasPutawayChanges || hasProcessChanges) {
  458. currentDetail = await saveBomAsNewVersionClient(detail.id, {
  459. materials: hasMaterialChanges
  460. ? materialPanel!.getMaterialSaveLines()
  461. : materialVersionLinesFromDetail(detail),
  462. ...(hasPutawayChanges
  463. ? {
  464. putawayLocationCode:
  465. basicPanel!.getPutawayLocationCodeForSave() ?? "",
  466. }
  467. : {}),
  468. ...(hasProcessChanges
  469. ? { processes: processPanel!.getProcessSaveLines() }
  470. : {}),
  471. });
  472. materialPanel?.cancelEdit();
  473. processPanel?.cancelEdit();
  474. setMaterialDirty(false);
  475. setProcessDirty(false);
  476. if (hasPutawayChanges && !hasBasicChanges) {
  477. basicPanel?.cancelEdit();
  478. setBasicInfoDirty(false);
  479. }
  480. sourceDetail = currentDetail;
  481. }
  482. if (hasBasicChanges) {
  483. const draft = basicPanel!.getDraft();
  484. if (draft) {
  485. currentDetail = await saveBasicInfoDraft(
  486. currentDetail.id,
  487. sourceDetail,
  488. draft,
  489. );
  490. basicPanel?.cancelEdit();
  491. setBasicInfoDirty(false);
  492. }
  493. }
  494. await refreshAfterSave(currentDetail);
  495. } catch (e: unknown) {
  496. const message = e instanceof Error ? e.message : "Failed to save BOM";
  497. setSaveError(message);
  498. } finally {
  499. setSaving(false);
  500. saveInFlightRef.current = false;
  501. }
  502. }, [
  503. detail,
  504. headerOutputQtyUnresolved,
  505. putawayLocationMissing,
  506. refreshAfterSave,
  507. t,
  508. unresolvedMaterialIssueCount,
  509. ]);
  510. const handleExportExcel = useCallback(async () => {
  511. if (typeof versionId !== "number" || exportInFlightRef.current) return;
  512. exportInFlightRef.current = true;
  513. setExporting(true);
  514. setSaveError(null);
  515. try {
  516. const code = (detail?.itemCode ?? currentBom?.code ?? "BOM").trim() || "BOM";
  517. const rev = detail?.revisionNo ?? 1;
  518. const { blob, fileName } = await downloadBomDetailExcelClient(
  519. versionId,
  520. `${code}_V${rev}.xlsx`,
  521. );
  522. const url = URL.createObjectURL(blob);
  523. const a = document.createElement("a");
  524. a.href = url;
  525. a.download = fileName;
  526. a.click();
  527. URL.revokeObjectURL(url);
  528. } catch (e: unknown) {
  529. setSaveError(e instanceof Error ? e.message : t("Export excel failed"));
  530. } finally {
  531. setExporting(false);
  532. exportInFlightRef.current = false;
  533. }
  534. }, [currentBom?.code, detail?.itemCode, detail?.revisionNo, t, versionId]);
  535. const handleToggleCompare = useCallback(async () => {
  536. if (compareMode) {
  537. setCompareMode(false);
  538. setCompareToId(versionId);
  539. setCompareOldDetail(null);
  540. return;
  541. }
  542. if (!currentBom?.code || versionId === "") return;
  543. const list = await fetchBomVersionsClient(
  544. currentBom.code,
  545. detail?.bomKind ?? detail?.description ?? "FG",
  546. );
  547. const other = list.find((v) => v.id !== versionId);
  548. setCompareToId(other?.id ?? versionId);
  549. setCompareMode(true);
  550. }, [compareMode, currentBom?.code, detail?.bomKind, detail?.description, versionId]);
  551. /*
  552. const handleResetBom = useCallback(() => {
  553. setFilteredBoms(bomList);
  554. setSelectedBomId("");
  555. setDetail(null);
  556. }, [bomList]);
  557. */
  558. const genKey = () => Math.random().toString(36).slice(2);
  559. const startEdit = useCallback(async () => {
  560. if (!detail) return;
  561. setEditError(null);
  562. setEditMasterLoading(true);
  563. try {
  564. const [equipments, processes] = await Promise.all([
  565. fetchAllEquipmentsMasterClient(),
  566. fetchAllProcessesMasterClient(),
  567. ]);
  568. setEquipmentMasterList(equipments);
  569. setProcessMasterList(processes);
  570. setEditBasic({
  571. description: detail.description ?? "",
  572. outputQty: detail.outputQty ?? 0,
  573. outputQtyUom: detail.outputQtyUom ?? "",
  574. isDark: detail.isDark ?? 0,
  575. isFloat: detail.isFloat ?? 0,
  576. isDense: detail.isDense ?? 0,
  577. scrapRate: detail.scrapRate ?? 0,
  578. allergicSubstances: detail.allergicSubstances ?? 0,
  579. timeSequence: detail.timeSequence ?? 0,
  580. complexity: detail.complexity ?? 0,
  581. isDrink: detail.isDrink ?? false,
  582. isPowderMixture: detail.isPowderMixture ?? false,
  583. });
  584. setEditMaterials(
  585. (detail.materials ?? []).map((m) => ({
  586. key: genKey(),
  587. id: undefined,
  588. itemCode: m.itemCode ?? "",
  589. itemName: m.itemName ?? "",
  590. qty: m.baseQty ?? 0,
  591. isConsumable: m.isConsumable ?? false,
  592. baseUom: m.baseUom,
  593. stockQty: m.stockQty,
  594. stockUom: m.stockUom,
  595. salesQty: m.salesQty,
  596. salesUom: m.salesUom,
  597. })),
  598. );
  599. setEditProcesses(
  600. (detail.processes ?? []).map((p) => {
  601. const code = (p.equipmentCode ?? "").trim();
  602. const eq = code
  603. ? equipments.find((e) => e.code === code)
  604. : undefined;
  605. return {
  606. key: genKey(),
  607. id: undefined,
  608. seqNo: p.seqNo,
  609. processCode: p.processCode ?? "",
  610. processName: p.processName,
  611. description: p.processDescription ?? "",
  612. equipmentDescription: eq?.description ?? "",
  613. equipmentName: eq?.name ?? "",
  614. durationInMinute: p.durationInMinute ?? 0,
  615. prepTimeInMinute: p.prepTimeInMinute ?? 0,
  616. postProdTimeInMinute: p.postProdTimeInMinute ?? 0,
  617. };
  618. }),
  619. );
  620. setIsEditing(true);
  621. } catch (e: unknown) {
  622. const msg =
  623. e && typeof e === "object" && "message" in e
  624. ? String((e as { message?: string }).message)
  625. : "載入製程/設備主檔失敗";
  626. setEditError(msg);
  627. } finally {
  628. setEditMasterLoading(false);
  629. }
  630. }, [detail]);
  631. const cancelEdit = useCallback(() => {
  632. setIsEditing(false);
  633. setEditLoading(false);
  634. setEditError(null);
  635. setEditBasic(null);
  636. setEditMaterials([]);
  637. setEditProcesses([]);
  638. setProcessAddForm({
  639. processCode: "",
  640. equipmentDescription: "",
  641. equipmentName: "",
  642. description: "",
  643. durationInMinute: 0,
  644. prepTimeInMinute: 0,
  645. postProdTimeInMinute: 0,
  646. });
  647. setEquipmentMasterList([]);
  648. setProcessMasterList([]);
  649. }, []);
  650. const addMaterialRow = useCallback(() => {
  651. setEditMaterials((prev) => [
  652. ...prev,
  653. {
  654. key: genKey(),
  655. itemCode: "",
  656. itemName: "",
  657. qty: 0,
  658. isConsumable: false,
  659. baseUom: "",
  660. stockQty: undefined,
  661. stockUom: "",
  662. salesQty: undefined,
  663. salesUom: "",
  664. },
  665. ]);
  666. }, []);
  667. const addProcessRow = useCallback(() => {
  668. setEditProcesses((prev) => [
  669. ...prev,
  670. {
  671. key: genKey(),
  672. seqNo: undefined,
  673. processCode: "",
  674. processName: "",
  675. description: "",
  676. equipmentDescription: "",
  677. equipmentName: "",
  678. durationInMinute: 0,
  679. prepTimeInMinute: 0,
  680. postProdTimeInMinute: 0,
  681. },
  682. ]);
  683. }, []);
  684. const addProcessFromForm = useCallback(() => {
  685. const pCode = processAddForm.processCode.trim();
  686. if (!pCode) {
  687. setEditError("請先選擇工序 Process Code");
  688. return;
  689. }
  690. const ed = processAddForm.equipmentDescription.trim();
  691. const en = processAddForm.equipmentName.trim();
  692. if ((ed && !en) || (!ed && en)) {
  693. setEditError("設備描述與名稱需同時選取,或同時留空(不適用)");
  694. return;
  695. }
  696. if (ed && en) {
  697. const resolved = resolveEquipmentCode(equipmentMasterList, ed, en);
  698. if (!resolved) {
  699. setEditError(
  700. `設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`,
  701. );
  702. return;
  703. }
  704. }
  705. setEditProcesses((prev) => [
  706. ...prev,
  707. {
  708. key: genKey(),
  709. seqNo: undefined,
  710. processCode: pCode,
  711. processName: "",
  712. description: processAddForm.description ?? "",
  713. equipmentDescription: ed,
  714. equipmentName: en,
  715. durationInMinute: processAddForm.durationInMinute ?? 0,
  716. prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0,
  717. postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0,
  718. },
  719. ]);
  720. setProcessAddForm({
  721. processCode: "",
  722. equipmentDescription: "",
  723. equipmentName: "",
  724. description: "",
  725. durationInMinute: 0,
  726. prepTimeInMinute: 0,
  727. postProdTimeInMinute: 0,
  728. });
  729. setEditError(null);
  730. }, [processAddForm, equipmentMasterList]);
  731. const deleteMaterialRow = useCallback((key: string) => {
  732. setEditMaterials((prev) => prev.filter((r) => r.key !== key));
  733. }, []);
  734. const deleteProcessRow = useCallback((key: string) => {
  735. setEditProcesses((prev) => prev.filter((r) => r.key !== key));
  736. }, []);
  737. const handleSaveEdit = useCallback(async () => {
  738. if (!detail || !editBasic) return;
  739. setEditLoading(true);
  740. setEditError(null);
  741. try {
  742. for (const p of editProcesses) {
  743. if (!p.processCode?.trim()) {
  744. throw new Error("工序行 Process Code 不能为空");
  745. }
  746. const ed = p.equipmentDescription.trim();
  747. const en = p.equipmentName.trim();
  748. if ((ed && !en) || (!ed && en)) {
  749. throw new Error("各製程行的設備描述與名稱需同時填寫或同時留空");
  750. }
  751. if (ed && en) {
  752. const resolved = resolveEquipmentCode(equipmentMasterList, ed, en);
  753. if (!resolved) {
  754. throw new Error(
  755. `設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`,
  756. );
  757. }
  758. }
  759. }
  760. const payload: any = {
  761. description: editBasic.description || undefined,
  762. outputQty: editBasic.outputQty,
  763. outputQtyUom: editBasic.outputQtyUom || undefined,
  764. isDark: editBasic.isDark,
  765. isFloat: editBasic.isFloat,
  766. isDense: editBasic.isDense,
  767. scrapRate: editBasic.scrapRate,
  768. allergicSubstances: editBasic.allergicSubstances,
  769. timeSequence: editBasic.timeSequence,
  770. complexity: editBasic.complexity,
  771. isDrink: editBasic.isDrink,
  772. isPowderMixture: editBasic.isPowderMixture,
  773. processes: editProcesses.map((p) => {
  774. const ed = p.equipmentDescription.trim();
  775. const en = p.equipmentName.trim();
  776. const equipmentCode =
  777. ed && en
  778. ? resolveEquipmentCode(equipmentMasterList, ed, en) ?? undefined
  779. : undefined;
  780. return {
  781. id: p.id,
  782. seqNo: p.seqNo,
  783. processCode: p.processCode?.trim() || undefined,
  784. equipmentCode,
  785. description: p.description || undefined,
  786. durationInMinute: p.durationInMinute,
  787. prepTimeInMinute: p.prepTimeInMinute,
  788. postProdTimeInMinute: p.postProdTimeInMinute,
  789. };
  790. }),
  791. };
  792. const updated = await editBomClient(detail.id, payload);
  793. setDetail(updated);
  794. setIsEditing(false);
  795. } catch (e: any) {
  796. setEditError(e?.message || "保存失败");
  797. } finally {
  798. setEditLoading(false);
  799. }
  800. }, [detail, editBasic, editProcesses, equipmentMasterList]);
  801. return (
  802. <Stack spacing={2}>
  803. <SearchBox<BomSearchKey>
  804. criteria={searchCriteria}
  805. onSearch={handleSearchBom}
  806. //onReset={handleResetBom}
  807. />
  808. {filteredBoms.length > 1 && (
  809. <Paper variant="outlined" sx={{ p: 1.5 }}>
  810. <Typography variant="subtitle2" sx={{ mb: 1 }}>
  811. 找到多筆 BOM,請選擇一筆載入明細
  812. </Typography>
  813. <Stack direction="row" spacing={1} flexWrap="wrap">
  814. {filteredBoms.map((b) => (
  815. <Button
  816. key={b.id}
  817. size="small"
  818. variant={selectedBomId === b.id ? "contained" : "outlined"}
  819. onClick={() => void loadBomDetail(b.id)}
  820. >
  821. {String(b.label ?? b.id)} ({renderType(b.description)}, {renderBomStatus(b.status)})
  822. </Button>
  823. ))}
  824. </Stack>
  825. </Paper>
  826. )}
  827. {detail && (
  828. <Stack spacing={2} sx={{ pointerEvents: saving || exporting ? "none" : "auto" }}>
  829. <Typography variant="subtitle1">
  830. {detail.itemCode} {detail.itemName}
  831. </Typography>
  832. {(currentBom?.code ?? detail.itemCode) && (
  833. <BomVersionControlBar
  834. bomCode={currentBom?.code ?? detail.itemCode ?? ""}
  835. bomKind={detail.bomKind ?? detail.description ?? "FG"}
  836. versionId={versionId}
  837. compareMode={compareMode}
  838. compareToId={compareToId}
  839. saving={saving}
  840. exporting={exporting}
  841. versionsRefreshKey={versionsRefreshKey}
  842. saveDisabled={effectiveSaveDisabled}
  843. exportDisabled={typeof versionId !== "number"}
  844. saveError={
  845. blockedByUnresolvedMaterialIssue
  846. ? unresolvedMaterialIssueMessage
  847. : saveError
  848. }
  849. onVersionChange={(id) => {
  850. setVersionId(id);
  851. void loadBomDetail(id, { resetVersionIds: false });
  852. }}
  853. onToggleCompare={() => void handleToggleCompare()}
  854. onCompareToChange={setCompareToId}
  855. onExport={() => void handleExportExcel()}
  856. onSave={() => void handleSave()}
  857. />
  858. )}
  859. {unresolvedMaterialIssueMessage && (
  860. <Typography variant="body2" color="warning.main" sx={{ mt: -1 }}>
  861. {unresolvedMaterialIssueMessage}
  862. </Typography>
  863. )}
  864. {putawayLocationMissingWarn && (
  865. <Typography variant="body2" color="warning.main" sx={{ mt: -1 }}>
  866. {putawayLocationMissingWarn}
  867. </Typography>
  868. )}
  869. <BomBasicInfoSection
  870. ref={basicInfoRef}
  871. detail={detail}
  872. compareOldDetail={compareOldDetail}
  873. comparing={isComparingVersions}
  874. editDisabled={compareMode}
  875. onDirtyChange={setBasicInfoDirty}
  876. />
  877. <BomMaterialEditPanel
  878. ref={materialEditRef}
  879. detail={detail}
  880. compareOldDetail={compareOldDetail}
  881. comparing={isComparingVersions}
  882. editDisabled={compareMode}
  883. onDirtyChange={setMaterialDirty}
  884. />
  885. <BomProcessEditPanel
  886. ref={processEditRef}
  887. detail={detail}
  888. compareOldDetail={compareOldDetail}
  889. comparing={isComparingVersions}
  890. onDirtyChange={setProcessDirty}
  891. />
  892. </Stack>
  893. )}
  894. </Stack>
  895. );
  896. };
  897. export default ImportBomDetailTab;