|
- "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,
- Grid,
- } from "@mui/material";
- import type { BomCombo, BomDetailResponse, BomStatus } from "@/app/api/bom";
- import {
- editBomClient,
- saveBomAsNewVersionClient,
- fetchBomComboClient,
- fetchBomVersionsClient,
- fetchBomDetailClient,
- fetchAllEquipmentsMasterClient,
- fetchAllProcessesMasterClient,
- downloadBomDetailExcelClient,
- 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";
- import { BomBasicInfoSection, type BomBasicInfoSectionHandle, saveBasicInfoDraft } from "./BomBasicInfoSection";
- import { BomVersionControlBar } from "./BomVersionControlBar";
- import {
- BomMaterialEditPanel,
- type BomMaterialEditPanelHandle,
- } from "./BomMaterialEditPanel";
- import {
- BomProcessEditPanel,
- type BomProcessEditPanelHandle,
- } from "./BomProcessEditPanel";
- import { getAmbiguousBomMatches, pickDefaultBom } from "./bomVersionUtils";
- import { materialVersionLinesFromDetail } from "./bomVersionSaveUtils";
-
- /** 以 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;
- }
-
- /** Full BOM field edit (materials/processes) — hidden until re-enabled. */
- const SHOW_BOM_FULL_EDIT = false;
-
- type BomSearchKey = "code" | "name";
- type BomSearchInputs = Record<BomSearchKey | `${BomSearchKey}To`, string>;
-
- const EMPTY_BOM_SEARCH_INPUTS: BomSearchInputs = {
- code: "",
- name: "",
- codeTo: "",
- nameTo: "",
- };
-
- /** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-16 */
- const ImportBomDetailTab: React.FC = () => {
- const { t } = useTranslation(["importBom", "common"]);
- const [bomList, setBomList] = useState<BomCombo[]>([]);
- const [selectedBomId, setSelectedBomId] = useState<number | "">("");
- const [detail, setDetail] = useState<BomDetailResponse | null>(null);
- const [loadingList, setLoadingList] = useState(false);
- const [versionsRefreshKey, setVersionsRefreshKey] = useState(0);
- const [filteredBoms, setFilteredBoms] = useState<BomCombo[]>([])
- const [currentBom, setCurrentBom] = useState<BomCombo | null>(null);
- const loadDetailInFlightRef = useRef(false);
- const saveInFlightRef = useRef(false);
- const exportInFlightRef = useRef(false);
- const skipAutoSearchRef = useRef(false);
- const detailIdRef = useRef<number | null>(null);
- const lastSearchInputsRef = useRef<BomSearchInputs>(EMPTY_BOM_SEARCH_INPUTS);
-
- 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<string | null>(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;
- isPowderMixture: boolean;
- } | null>(null);
-
- const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]);
- const [editProcesses, setEditProcesses] = useState<EditProcessRow[]>([]);
-
- const [equipmentMasterList, setEquipmentMasterList] = useState<
- EquipmentMasterRow[]
- >([]);
- const [processMasterList, setProcessMasterList] = useState<
- ProcessMasterRow[]
- >([]);
- const [editMasterLoading, setEditMasterLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [exporting, setExporting] = useState(false);
- const [saveError, setSaveError] = useState<string | null>(null);
- const [versionId, setVersionId] = useState<number | "">("");
- const [compareMode, setCompareMode] = useState(false);
- const [compareToId, setCompareToId] = useState<number | "">("");
- const [compareOldDetail, setCompareOldDetail] = useState<BomDetailResponse | null>(null);
- const materialEditRef = useRef<BomMaterialEditPanelHandle>(null);
- const processEditRef = useRef<BomProcessEditPanelHandle>(null);
- const basicInfoRef = useRef<BomBasicInfoSectionHandle>(null);
- const [materialDirty, setMaterialDirty] = useState(false);
- const [processDirty, setProcessDirty] = useState(false);
- const [basicInfoDirty, setBasicInfoDirty] = 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<string>();
- processMasterList.forEach((p) => {
- if (p.code) codes.add(p.code);
- });
- return Array.from(codes).sort();
- }, [processMasterList]);
-
- const equipmentDescriptionOptions = useMemo(() => {
- const s = new Set<string>();
- equipmentMasterList.forEach((e) => {
- if (e.description) s.add(e.description);
- });
- return Array.from(s).sort();
- }, [equipmentMasterList]);
-
- const equipmentNameOptions = useMemo(() => {
- const s = new Set<string>();
- 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({ includeInactive: true });
- setBomList(list);
- } finally {
- setLoadingList(false);
- }
- };
- loadList();
- }, []);
- const searchCriteria: Criterion<BomSearchKey>[] = useMemo(
- () => [
- { label: t("Code"), paramName: "code", type: "text" },
- { label: t("Name"), paramName: "name", type: "text" },
- ],
- [t],
- );
- useEffect(() => {
- setFilteredBoms([]);
- }, [bomList]);
- useEffect(() => {
- detailIdRef.current = detail?.id ?? null;
- }, [detail?.id]);
-
- const recomputeFilteredBoms = useCallback(
- (list: BomCombo[], inputs: BomSearchInputs) => {
- const code = (inputs.code ?? "").trim().toLowerCase();
- const name = (inputs.name ?? "").trim().toLowerCase();
- const matched = list.filter((b) => {
- const label = String(b.label ?? "").toLowerCase();
- const okCode = !code || label.includes(code);
- const okName = !name || label.includes(name);
- return okCode && okName;
- });
- setFilteredBoms(getAmbiguousBomMatches(matched));
- return matched;
- },
- [],
- );
-
- const loadBomDetail = useCallback(
- async (id: number, options?: { resetVersionIds?: boolean }) => {
- if (!id || loadDetailInFlightRef.current) return;
- loadDetailInFlightRef.current = true;
- setSelectedBomId(id);
- setCurrentBom(bomList.find((b) => b.id === id) ?? null);
-
- try {
- const d = await fetchBomDetailClient(id);
- setDetail(d);
- setSaveError(null);
- if (options?.resetVersionIds !== false) {
- setVersionId(id);
- setCompareMode(false);
- setCompareToId(id);
- setCompareOldDetail(null);
- }
- } finally {
- loadDetailInFlightRef.current = false;
- }
- },
- [bomList],
- );
-
- useEffect(() => {
- if (
- !compareMode ||
- !compareToId ||
- !versionId ||
- compareToId === versionId
- ) {
- setCompareOldDetail(null);
- return;
- }
- let cancelled = false;
- void fetchBomDetailClient(compareToId).then((d) => {
- if (!cancelled) setCompareOldDetail(d);
- });
- return () => {
- cancelled = true;
- };
- }, [compareMode, compareToId, versionId]);
-
- const handleSearchBom = useCallback(
- (inputs: BomSearchInputs) => {
- lastSearchInputsRef.current = inputs;
- const code = (inputs.code ?? "").trim().toLowerCase();
- const name = (inputs.name ?? "").trim().toLowerCase();
-
- const matched = bomList.filter((b) => {
- const label = String(b.label ?? "").toLowerCase();
- const okCode = !code || label.includes(code);
- const okName = !name || label.includes(name);
- return okCode && okName;
- });
-
- const picked = pickDefaultBom(matched);
- setFilteredBoms(getAmbiguousBomMatches(matched));
-
- if (picked) {
- if (picked.id === detailIdRef.current) return;
- void loadBomDetail(picked.id);
- } else if (matched.length === 0) {
- setSelectedBomId("");
- setCurrentBom(null);
- setDetail(null);
- } else {
- setSelectedBomId("");
- setCurrentBom(null);
- setDetail(null);
- }
- },
- [bomList, loadBomDetail],
- );
-
- useEffect(() => {
- if (bomList.length === 0 || skipAutoSearchRef.current) return;
- const inputs = lastSearchInputsRef.current;
- const hasQuery = Boolean((inputs.code ?? "").trim() || (inputs.name ?? "").trim());
- if (!hasQuery) return;
-
- const code = (inputs.code ?? "").trim().toLowerCase();
- const name = (inputs.name ?? "").trim().toLowerCase();
- const matched = bomList.filter((b) => {
- const label = String(b.label ?? "").toLowerCase();
- const okCode = !code || label.includes(code);
- const okName = !name || label.includes(name);
- return okCode && okName;
- });
- const picked = pickDefaultBom(matched);
- setFilteredBoms(getAmbiguousBomMatches(matched));
- if (!picked || picked.id === detailIdRef.current) return;
- void loadBomDetail(picked.id);
- }, [bomList, loadBomDetail]);
- const renderBomStatus = (v?: BomStatus | string) => {
- if (v === "active") return t("BOM Status Active");
- if (v === "inactive") return t("BOM Status Inactive");
- return "-";
- };
-
- const isComparingVersions =
- compareMode &&
- Boolean(compareOldDetail) &&
- compareToId !== "" &&
- versionId !== "" &&
- compareToId !== versionId;
-
- const renderType = (v?: string) => {
- if (v === "FG") return "成品";
- if (v === "WIP") return "半成品";
- return "-";
- };
-
- const saveDisabled = !materialDirty && !basicInfoDirty && !processDirty;
- const unresolvedMaterialIssueCount = useMemo(
- () =>
- (detail?.materials ?? []).filter(
- (m) =>
- (m.baseQty == null || m.stockQty == null) &&
- (m.recipeQty ?? m.baseQty ?? 0) > 0,
- ).length,
- [detail?.materials],
- );
- const nonMaterialDirty = basicInfoDirty || processDirty;
- const blockedByUnresolvedMaterialIssue =
- unresolvedMaterialIssueCount > 0 && nonMaterialDirty && !materialDirty;
- const unresolvedMaterialIssueMessage =
- unresolvedMaterialIssueCount > 0
- ? t("bomSave_block_unresolvedMaterialIssue", {
- count: unresolvedMaterialIssueCount,
- })
- : null;
-
- const isFgDetail = useMemo(() => {
- if (!detail) return false;
- const kind = (detail.bomKind ?? detail.description ?? "FG").trim().toUpperCase();
- return kind !== "WIP";
- }, [detail]);
- const putawayLocationMissing = isFgDetail && !(detail?.putawayLocationCode ?? "").trim();
- const headerOutputQtyUnresolved = detail?.outputQtyStockConvertible === false;
- const putawayLocationMissingWarn = putawayLocationMissing
- ? t("bomPutawayLocation_missing_warn")
- : null;
- // Do not hard-disable Save purely from `detail` flags,
- // because user may still be editing draft values (putaway/header fix).
- // We enforce these blockers again in `handleSave()` using draft state.
- const effectiveSaveDisabled =
- saveDisabled || blockedByUnresolvedMaterialIssue;
-
- const refreshAfterSave = useCallback(async (saved: BomDetailResponse) => {
- const full = await fetchBomDetailClient(saved.id);
- detailIdRef.current = full.id;
- skipAutoSearchRef.current = true;
- setDetail(full);
- setVersionId(full.id);
- setCompareToId(full.id);
- setCompareMode(false);
- setCompareOldDetail(null);
- setVersionsRefreshKey((k) => k + 1);
-
- try {
- const list = await fetchBomComboClient({ includeInactive: true });
- setBomList(list);
- setCurrentBom(list.find((b) => b.id === full.id) ?? null);
- const inputs = lastSearchInputsRef.current;
- const hasQuery = Boolean(
- (inputs.code ?? "").trim() || (inputs.name ?? "").trim(),
- );
- if (hasQuery) {
- recomputeFilteredBoms(list, inputs);
- }
- } finally {
- skipAutoSearchRef.current = false;
- }
- }, [recomputeFilteredBoms]);
-
- const handleSave = useCallback(async () => {
- if (!detail?.id || saveInFlightRef.current) return;
- const materialPanel = materialEditRef.current;
- const processPanel = processEditRef.current;
- const basicPanel = basicInfoRef.current;
- const hasMaterialChanges = materialPanel?.hasMaterialChanges() ?? false;
- const hasProcessChanges = processPanel?.hasProcessChanges() ?? false;
- const hasBasicChanges = basicPanel?.hasBasicChanges() ?? false;
- const hasPutawayChanges = basicPanel?.hasPutawayChanges() ?? false;
-
- if (!hasMaterialChanges && !hasBasicChanges && !hasPutawayChanges && !hasProcessChanges) return;
-
- const putawayMissing = putawayLocationMissing && !hasPutawayChanges;
- if (putawayMissing) {
- setSaveError(t("bomPutawayLocation_missing_warn"));
- return;
- }
-
- const headerUnresolved = headerOutputQtyUnresolved;
- const headerDraft = basicPanel?.getDraft();
- const headerChangedInDraft =
- headerDraft != null &&
- (headerDraft.outputQty !== detail.outputQty ||
- (headerDraft.outputQtyUom ?? "") !== (detail.outputQtyUom ?? ""));
- if (headerUnresolved && !headerChangedInDraft) {
- setSaveError(t("bomHeaderOutputQtyStockConvertFail_warn"));
- return;
- }
- if (unresolvedMaterialIssueCount > 0 && !hasMaterialChanges && (hasBasicChanges || hasProcessChanges || hasPutawayChanges)) {
- setSaveError(
- t("bomSave_block_unresolvedMaterialIssue", {
- count: unresolvedMaterialIssueCount,
- }),
- );
- return;
- }
-
- const basicValidation = basicPanel?.validate() ?? null;
- if (basicValidation) {
- setSaveError(basicValidation);
- return;
- }
-
- const processValidation = (await processPanel?.validateAsync()) ?? null;
- if (processValidation) {
- setSaveError(processValidation);
- return;
- }
-
- saveInFlightRef.current = true;
- setSaving(true);
- setSaveError(null);
- try {
- let currentDetail = detail;
- let sourceDetail = detail;
-
- if (hasMaterialChanges || hasPutawayChanges || hasProcessChanges) {
- currentDetail = await saveBomAsNewVersionClient(detail.id, {
- materials: hasMaterialChanges
- ? materialPanel!.getMaterialSaveLines()
- : materialVersionLinesFromDetail(detail),
- ...(hasPutawayChanges
- ? {
- putawayLocationCode:
- basicPanel!.getPutawayLocationCodeForSave() ?? "",
- }
- : {}),
- ...(hasProcessChanges
- ? { processes: processPanel!.getProcessSaveLines() }
- : {}),
- });
- materialPanel?.cancelEdit();
- processPanel?.cancelEdit();
- setMaterialDirty(false);
- setProcessDirty(false);
- if (hasPutawayChanges && !hasBasicChanges) {
- basicPanel?.cancelEdit();
- setBasicInfoDirty(false);
- }
- sourceDetail = currentDetail;
- }
-
- if (hasBasicChanges) {
- const draft = basicPanel!.getDraft();
- if (draft) {
- currentDetail = await saveBasicInfoDraft(
- currentDetail.id,
- sourceDetail,
- draft,
- );
- basicPanel?.cancelEdit();
- setBasicInfoDirty(false);
- }
- }
-
- await refreshAfterSave(currentDetail);
- } catch (e: unknown) {
- const message = e instanceof Error ? e.message : "Failed to save BOM";
- setSaveError(message);
- } finally {
- setSaving(false);
- saveInFlightRef.current = false;
- }
- }, [
- detail,
- headerOutputQtyUnresolved,
- putawayLocationMissing,
- refreshAfterSave,
- t,
- unresolvedMaterialIssueCount,
- ]);
-
- const handleExportExcel = useCallback(async () => {
- if (typeof versionId !== "number" || exportInFlightRef.current) return;
- exportInFlightRef.current = true;
- setExporting(true);
- setSaveError(null);
- try {
- const code = (detail?.itemCode ?? currentBom?.code ?? "BOM").trim() || "BOM";
- const rev = detail?.revisionNo ?? 1;
- const { blob, fileName } = await downloadBomDetailExcelClient(
- versionId,
- `${code}_V${rev}.xlsx`,
- );
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = fileName;
- a.click();
- URL.revokeObjectURL(url);
- } catch (e: unknown) {
- setSaveError(e instanceof Error ? e.message : t("Export excel failed"));
- } finally {
- setExporting(false);
- exportInFlightRef.current = false;
- }
- }, [currentBom?.code, detail?.itemCode, detail?.revisionNo, t, versionId]);
-
- const handleToggleCompare = useCallback(async () => {
- if (compareMode) {
- setCompareMode(false);
- setCompareToId(versionId);
- setCompareOldDetail(null);
- return;
- }
- if (!currentBom?.code || versionId === "") return;
- const list = await fetchBomVersionsClient(
- currentBom.code,
- detail?.bomKind ?? detail?.description ?? "FG",
- );
- const other = list.find((v) => v.id !== versionId);
- setCompareToId(other?.id ?? versionId);
- setCompareMode(true);
- }, [compareMode, currentBom?.code, detail?.bomKind, detail?.description, versionId]);
-
- /*
- 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,
- isPowderMixture: detail.isPowderMixture ?? 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,
- isPowderMixture: editBasic.isPowderMixture,
- 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 (
- <Stack spacing={2}>
- <SearchBox<BomSearchKey>
- criteria={searchCriteria}
- onSearch={handleSearchBom}
- //onReset={handleResetBom}
- />
-
- {filteredBoms.length > 1 && (
- <Paper variant="outlined" sx={{ p: 1.5 }}>
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
- 找到多筆 BOM,請選擇一筆載入明細
- </Typography>
- <Stack direction="row" spacing={1} flexWrap="wrap">
- {filteredBoms.map((b) => (
- <Button
- key={b.id}
- size="small"
- variant={selectedBomId === b.id ? "contained" : "outlined"}
- onClick={() => void loadBomDetail(b.id)}
- >
- {String(b.label ?? b.id)} ({renderType(b.description)}, {renderBomStatus(b.status)})
- </Button>
- ))}
- </Stack>
- </Paper>
- )}
- {detail && (
- <Stack spacing={2} sx={{ pointerEvents: saving || exporting ? "none" : "auto" }}>
- <Typography variant="subtitle1">
- {detail.itemCode} {detail.itemName}
- </Typography>
-
- {(currentBom?.code ?? detail.itemCode) && (
- <BomVersionControlBar
- bomCode={currentBom?.code ?? detail.itemCode ?? ""}
- bomKind={detail.bomKind ?? detail.description ?? "FG"}
- versionId={versionId}
- compareMode={compareMode}
- compareToId={compareToId}
- saving={saving}
- exporting={exporting}
- versionsRefreshKey={versionsRefreshKey}
- saveDisabled={effectiveSaveDisabled}
- exportDisabled={typeof versionId !== "number"}
- saveError={
- blockedByUnresolvedMaterialIssue
- ? unresolvedMaterialIssueMessage
- : saveError
- }
- onVersionChange={(id) => {
- setVersionId(id);
- void loadBomDetail(id, { resetVersionIds: false });
- }}
- onToggleCompare={() => void handleToggleCompare()}
- onCompareToChange={setCompareToId}
- onExport={() => void handleExportExcel()}
- onSave={() => void handleSave()}
- />
- )}
-
- {unresolvedMaterialIssueMessage && (
- <Typography variant="body2" color="warning.main" sx={{ mt: -1 }}>
- {unresolvedMaterialIssueMessage}
- </Typography>
- )}
-
- {putawayLocationMissingWarn && (
- <Typography variant="body2" color="warning.main" sx={{ mt: -1 }}>
- {putawayLocationMissingWarn}
- </Typography>
- )}
-
- <BomBasicInfoSection
- ref={basicInfoRef}
- detail={detail}
- compareOldDetail={compareOldDetail}
- comparing={isComparingVersions}
- editDisabled={compareMode}
- onDirtyChange={setBasicInfoDirty}
- />
-
- <BomMaterialEditPanel
- ref={materialEditRef}
- detail={detail}
- compareOldDetail={compareOldDetail}
- comparing={isComparingVersions}
- editDisabled={compareMode}
- onDirtyChange={setMaterialDirty}
- />
-
- <BomProcessEditPanel
- ref={processEditRef}
- detail={detail}
- compareOldDetail={compareOldDetail}
- comparing={isComparingVersions}
- onDirtyChange={setProcessDirty}
- />
- </Stack>
- )}
- </Stack>
- );
- };
-
- export default ImportBomDetailTab;
|