From 85b482d9181d4e389a0a5f9a4dd66f5d8e86f0d8 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 16 Jul 2026 13:07:05 +0800 Subject: [PATCH] Import Bom Excel check if excel have issue will show and can veiw import data and can edit correct, and till isuue fix other wise not allow save. issue import bom check those check If Is FG but item LocationCode is null. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bom detail can edit bom have version compare can switch version of bom and select version to active/inactive 排程 imporve efficent of predect排程, export excel function, and else BOM/貨品單位問題 if current active bom uom can not convert to item base uom, will be issue show in this page if current active bom's bommaterail qty can not convert to item base uom , will be issue show in this page have fix form to update bom/bom materail data to help convert to base uom correct jobOrder Create if Bom have 單位問題, will not allow create Job Order bom martail remove column base/stock/sale qty/uom bom qty/uom from store stock change to base And these two have realte convert function to convert back realte uom/qty need to use --- src/app/(main)/jo/page.tsx | 11 +- src/app/(main)/settings/importBom/page.tsx | 2 +- src/app/api/bom/client.ts | 147 +++ src/app/api/bom/index.ts | 164 ++- src/app/api/jo/actions.ts | 12 +- .../api/masterDataIssues/bomUomAlignment.ts | 97 ++ src/app/api/masterDataIssues/client.ts | 76 ++ src/app/api/masterDataIssues/index.ts | 8 + .../ImportBom/BomAttributeScaleDisplay.tsx | 543 ++++++++ .../ImportBom/BomBasicInfoSection.tsx | 797 +++++++++++ .../ImportBom/BomImportMaterialEditTable.tsx | 437 ++++++ .../ImportBom/BomImportPreviewDialog.tsx | 1169 +++++++++++++++++ .../ImportBom/BomMaterialEditPanel.tsx | 456 +++++++ .../ImportBom/BomProcessEditPanel.tsx | 777 +++++++++++ .../ImportBom/BomPutawayLocationFields.tsx | 61 + .../ImportBom/BomVersionControlBar.tsx | 234 ++++ .../ImportBom/ImportBomDetailTab.tsx | 1128 ++++++---------- .../ImportBom/ImportBomResultForm.tsx | 687 ++++++++-- src/components/ImportBom/ImportBomUpload.tsx | 1 + .../ImportBom/bomAttributeScales.ts | 501 +++++++ src/components/ImportBom/bomDiff.tsx | 117 ++ .../ImportBom/bomImportFormatFieldErrors.ts | 205 +++ src/components/ImportBom/bomImportKindMode.ts | 34 + .../ImportBom/bomImportPreviewUtils.ts | 377 ++++++ src/components/ImportBom/bomProcessUtils.ts | 115 ++ src/components/ImportBom/bomVersionCompare.ts | 43 + .../ImportBom/bomVersionSaveUtils.ts | 100 ++ src/components/ImportBom/bomVersionUtils.ts | 46 + .../ImportBom/putawayLocationUtils.ts | 52 + src/components/JoSearch/JoCreateFormModal.tsx | 14 +- src/components/JoSearch/JoSearch.tsx | 10 +- src/components/JoSearch/JoSearchWrapper.tsx | 20 +- .../JoWorkbench/JoWorkbenchSearch.tsx | 10 +- .../BomUomAlignmentDialog.tsx | 808 ++++++++++++ .../MasterDataIssuesPanel.tsx | 127 +- .../MasterDataIssues/MasterDataIssuesTabs.tsx | 16 +- .../MasterDataIssues/buildDisplayLines.ts | 150 +-- .../MasterDataIssues/groupMasterDataIssues.ts | 11 +- .../JobOrderBomAttributesGrid.tsx | 55 + .../ProductionProcessJobOrderDetail.tsx | 13 +- .../ScheduleTable/BomMaterialTable.tsx | 2 +- src/components/Shop/Shop.tsx | 2 +- src/hooks/useMasterDataIssueNavCount.ts | 10 +- src/i18n/en/importBom.json | 11 +- src/i18n/en/jo.json | 5 +- src/i18n/en/masterDataIssue.json | 35 +- src/i18n/en/productionProcess.json | 3 +- src/i18n/zh/importBom.json | 120 +- src/i18n/zh/jo.json | 15 +- src/i18n/zh/masterDataIssue.json | 35 +- src/i18n/zh/productionProcess.json | 10 +- 51 files changed, 8768 insertions(+), 1111 deletions(-) create mode 100644 src/app/api/masterDataIssues/bomUomAlignment.ts create mode 100644 src/components/ImportBom/BomAttributeScaleDisplay.tsx create mode 100644 src/components/ImportBom/BomBasicInfoSection.tsx create mode 100644 src/components/ImportBom/BomImportMaterialEditTable.tsx create mode 100644 src/components/ImportBom/BomImportPreviewDialog.tsx create mode 100644 src/components/ImportBom/BomMaterialEditPanel.tsx create mode 100644 src/components/ImportBom/BomProcessEditPanel.tsx create mode 100644 src/components/ImportBom/BomPutawayLocationFields.tsx create mode 100644 src/components/ImportBom/BomVersionControlBar.tsx create mode 100644 src/components/ImportBom/bomAttributeScales.ts create mode 100644 src/components/ImportBom/bomDiff.tsx create mode 100644 src/components/ImportBom/bomImportFormatFieldErrors.ts create mode 100644 src/components/ImportBom/bomImportKindMode.ts create mode 100644 src/components/ImportBom/bomImportPreviewUtils.ts create mode 100644 src/components/ImportBom/bomProcessUtils.ts create mode 100644 src/components/ImportBom/bomVersionCompare.ts create mode 100644 src/components/ImportBom/bomVersionSaveUtils.ts create mode 100644 src/components/ImportBom/bomVersionUtils.ts create mode 100644 src/components/ImportBom/putawayLocationUtils.ts create mode 100644 src/components/MasterDataIssues/BomUomAlignmentDialog.tsx create mode 100644 src/components/ProductionProcess/JobOrderBomAttributesGrid.tsx diff --git a/src/app/(main)/jo/page.tsx b/src/app/(main)/jo/page.tsx index 295b60b..0d7f2a1 100644 --- a/src/app/(main)/jo/page.tsx +++ b/src/app/(main)/jo/page.tsx @@ -1,4 +1,4 @@ -import { fetchBomCombo } from "@/app/api/bom"; +import { fetchBomCombo, fetchBomComboIssues } from "@/app/api/bom"; import { fetchPrinterCombo } from "@/app/api/settings/printer"; import { fetchAllJobTypes, type SearchJoResultRequest } from "@/app/api/jo/actions"; import GeneralLoading from "@/components/General/GeneralLoading"; @@ -23,11 +23,17 @@ const Jo: React.FC = async () => { planStartTo: `${todayStr}T23:59:59`, joSearchStatus: "all", }; - const [bomCombo, printerCombo, jobTypes] = await Promise.all([ + const [bomCombo, bomComboIssues, printerCombo, jobTypes] = await Promise.all([ fetchBomCombo(), + fetchBomComboIssues(), fetchPrinterCombo(), fetchAllJobTypes(), ]); + const bomIssueCountByBomId = (bomComboIssues ?? []).reduce>((acc, issue) => { + const id = issue.bomId; + acc[id] = (acc[id] ?? 0) + 1; + return acc; + }, {}); return ( <> @@ -37,6 +43,7 @@ const Jo: React.FC = async () => { diff --git a/src/app/(main)/settings/importBom/page.tsx b/src/app/(main)/settings/importBom/page.tsx index 26f4b21..f1d952f 100644 --- a/src/app/(main)/settings/importBom/page.tsx +++ b/src/app/(main)/settings/importBom/page.tsx @@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; export const metadata: Metadata = { - title: "Import BOM", + title: "匯入BOM", }; export default async function ImportBomPage() { diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts index 8d1db36..6b3f571 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -9,6 +9,12 @@ import type { BomCombo, BomDetailResponse, EditBomRequest, + BomVersionSummary, + SaveBomAsNewVersionRequest, + BomImportPreviewResponse, + BomProductType, + BomImportItemOverrides, + BomImportRevalidateResponse, } from "./index"; export async function uploadBomFiles( @@ -55,6 +61,85 @@ export async function downloadBomFormatIssueLog( ); return response.data as Blob; } +export async function fetchBomImportPreviewClient( + batchId: string, + fileNames?: string[], +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/preview`, + { batchId, fileNames }, + ); + return response.data; +} + +export async function revalidateImportBomItemClient( + batchId: string, + fileName: string, + overrides?: BomImportItemOverrides, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/revalidate`, + { batchId, fileName, overrides }, + ); + return response.data; +} + +export type BomImportMaterialItemContext = { + exists: boolean; + itemCode: string; + itemId?: number | null; + itemName?: string | null; + salesUomId?: number | null; + stockUomId?: number | null; + baseUomId?: number | null; + availableRecipeUoms?: Array<{ uomId: number; label: string; code?: string | null }>; +}; + +export async function fetchImportBomMaterialItemContextClient( + itemCode: string, + opts?: { currentUomCode?: string; currentUomId?: number }, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/material-item-context`, + { + params: { + itemCode: itemCode.trim(), + currentUomCode: opts?.currentUomCode?.trim() || undefined, + currentUomId: opts?.currentUomId || undefined, + }, + }, + ); + return response.data; +} + +export async function downloadCorrectedImportBomExcelClient( + batchId: string, + fileName: string, + overrides?: BomImportItemOverrides, +): Promise<{ blob: Blob; fileName: string }> { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/export-corrected`, + { batchId, fileName, overrides }, + { responseType: "blob" }, + ); + // Backend does not return filename (avoids non-Latin-1 Content-Disposition). Bump v.X locally. + const downloadName = fileName.replace(/v\.(\d+)/i, (_, n: string) => `v.${Number(n) + 1}`); + return { + blob: response.data as Blob, + fileName: downloadName || fileName, + }; +} + +export function bomProductTypeToImportFlags(productType: BomProductType): { + isDrink: boolean; + isPowderMixture: boolean; +} { + return { + isDrink: productType === "drink", + isPowderMixture: productType === "powder_mixture", + }; +} + export async function importBom( batchId: string, items: ImportBomItemPayload[] @@ -107,6 +192,37 @@ export async function fetchBomComboClient(options?: { ); return response.data; } + + export async function activateBomVersionClient( + id: number, + ): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/${id}/activate-version`, + ); + return response.data; + } + + export async function fetchBomVersionsClient( + code: string, + bomKind: string, + ): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/bom/versions`, + { params: { code, bomKind } }, + ); + return response.data; + } + + export async function saveBomAsNewVersionClient( + sourceBomId: number, + request: SaveBomAsNewVersionRequest, + ): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/${sourceBomId}/save-as-new-version`, + request, + ); + return response.data; + } export type BomExcelCheckProgress = { batchId: string; totalFiles: number; @@ -138,6 +254,37 @@ export type ProcessMasterRow = { name: string; }; +export async function fetchBagItemsComboClient(): Promise< + Array<{ code: string; name: string }> +> { + const response = await axiosInstance.get>( + `${NEXT_PUBLIC_API_URL}/items/bag-combo`, + ); + return Array.isArray(response.data) ? response.data : []; +} + +export async function fetchItemExistsByCodeClient( + code: string, +): Promise<{ exists: boolean; code: string; name?: string }> { + const response = await axiosInstance.get<{ exists: boolean; code: string; name?: string }>( + `${NEXT_PUBLIC_API_URL}/items/exists-by-code`, + { params: { code: code.trim() } }, + ); + return response.data; +} + +export async function fetchItemsExistsByCodesClient( + codes: string[], +): Promise> { + if (!codes || codes.length === 0) return []; + + const response = await axiosInstance.post< + Array<{ exists: boolean; code: string; name?: string }> + >(`${NEXT_PUBLIC_API_URL}/items/exists-by-codes`, codes); + + return Array.isArray(response.data) ? response.data : []; +} + export async function fetchAllEquipmentsMasterClient(): Promise< EquipmentMasterRow[] > { diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts index 89ff2a4..cf9e81d 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -7,10 +7,13 @@ export type BomStatus = "active" | "inactive"; export interface BomCombo { id: number; value: number; + code?: string; + revisionNo?: number; label: string; outputQty: number; outputQtyUom: string; description: string; + bomKind?: string; status?: BomStatus; } @@ -29,7 +32,7 @@ export interface BomComboIssue { bomName: string | null; itemId: number | null; description: string | null; - issueCode: BomComboIssueCode; + issueCode: string; } export interface BomFormatFileGroup { @@ -54,6 +57,89 @@ export interface ImportBomItemPayload { isAlsoWip: boolean; isDrink: boolean; isPowderMixture: boolean; + overrides?: BomImportItemOverrides; +} + +export type BomProductType = "drink" | "powder_mixture" | "other"; + +export interface BomImportPreviewMaterialLine { + itemCode?: string | null; + itemName?: string | null; + itemId?: number | null; + qty?: number | null; + uomCode?: string | null; + uomId?: number | null; + salesUomId?: number | null; + stockUomId?: number | null; + baseUomId?: number | null; + availableRecipeUoms?: BomMaterialUomOption[]; + baseQty?: number | null; + baseUom?: string | null; + stockQty?: number | null; + stockUom?: string | null; + joinStepSeqNo?: number | null; +} + +export interface BomImportPreviewProcessLine { + seqNo?: number | null; + processCode?: string | null; + processName?: string | null; + description?: string | null; + byProduct?: string | null; + byProductUom?: string | null; + equipmentDescription?: string | null; + equipmentName?: string | null; + durationInMinute?: number | null; + prepTimeInMinute?: number | null; + postProdTimeInMinute?: number | null; +} + +export interface BomImportItemOverrides { + code?: string | null; + name?: string | null; + bomKind?: string | null; + outputQty?: number | null; + outputQtyUom?: string | null; + isDark?: number | null; + isFloat?: number | null; + isDense?: number | null; + scrapRate?: number | null; + timeSequence?: number | null; + complexity?: number | null; + allergicSubstances?: number | null; + putawayLocationCode?: string | null; + materials?: BomImportPreviewMaterialLine[]; + processes?: BomImportPreviewProcessLine[]; +} + +export interface BomImportPreviewLine { + fileName: string; + code?: string | null; + name?: string | null; + bomKind?: string | null; + outputQty?: number | null; + outputQtyUom?: string | null; + isDark?: number | null; + isFloat?: number | null; + isDense?: number | null; + scrapRate?: number | null; + timeSequence?: number | null; + complexity?: number | null; + allergicSubstances?: number | null; + putawayLocationCode?: string | null; + materialCount?: number; + processCount?: number; + materials?: BomImportPreviewMaterialLine[]; + processes?: BomImportPreviewProcessLine[]; +} + +export interface BomImportPreviewResponse { + items: BomImportPreviewLine[]; +} + +export interface BomImportRevalidateResponse { + passed: boolean; + problems: string[]; } export const preloadBomCombo = (() => { @@ -74,31 +160,58 @@ export const fetchBomCombo = cache(async () => { }); }); +export const fetchBomComboIssues = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/bom/combo/issues`, { + next: { tags: ["bomComboIssues"] }, + }); +}); + export const fetchBomScores = cache(async () => { return serverFetchJson(`${BASE_API_URL}/bom/scores`, { next: { tags: ["boms"] }, }); }); +export interface BomMaterialUomOption { + uomId: number; + label: string; + code?: string; +} + export interface BomMaterialDto { + id?: number; + itemId?: number; itemCode?: string; itemName?: string; isConsumable?: boolean; + recipeQty?: number; + recipeUom?: string; + recipeUomId?: number; + salesUomId?: number; + stockUomId?: number; + baseUomId?: number; + availableRecipeUoms?: BomMaterialUomOption[]; baseQty?: number; baseUom?: string; stockQty?: number; stockUom?: string; salesQty?: number; salesUom?: string; + processSteps?: string[]; + processStepIds?: number[]; } export interface BomProcessDto { + id?: number; seqNo?: number; processCode?: string; processName?: string; processDescription?: string; + byProduct?: string; + byProductUom?: string; equipmentCode?: string; equipmentName?: string; + equipmentDescription?: string; durationInMinute?: number; prepTimeInMinute?: number; postProdTimeInMinute?: number; @@ -106,6 +219,7 @@ export interface BomProcessDto { export interface BomDetailResponse { id: number; + itemId?: number; itemCode?: string; itemName?: string; isDark?: number; @@ -119,13 +233,60 @@ export interface BomDetailResponse { complexity?: number; baseScore?: number; description?: string; + bomKind?: string; + revisionNo?: number; outputQty?: number; outputQtyUom?: string; + outputQtyStock?: number; + outputQtyStockUom?: string; + outputQtyStockUomId?: number; + outputQtyStockConvertible?: boolean; status?: BomStatus; + putawayLocationCode?: string; + itemLocationCode?: string; materials: BomMaterialDto[]; processes: BomProcessDto[]; } +export interface BomVersionSummary { + id: number; + revisionNo: number; + status: BomStatus; + bomKind?: string; + outputQty?: number; + outputQtyUom?: string; +} + +export interface BomMaterialVersionEditLine { + sourceBomMaterialId?: number; + itemCode: string; + qty: number; + uomId: number; + isConsumable?: boolean; + bomProcessIds?: number[]; +} + +export interface BomProcessVersionEditLine { + seqNo?: number; + processCode: string; + description?: string; + byProduct?: string; + byProductUom?: string; + equipmentDescription?: string; + equipmentName?: string; + equipmentCode?: string; + durationInMinute?: number; + prepTimeInMinute?: number; + postProdTimeInMinute?: number; +} + +export interface SaveBomAsNewVersionRequest { + materials: BomMaterialVersionEditLine[]; + /** When set (including ""), applied on new revision. Omit to copy from source. */ + putawayLocationCode?: string | null; + processes?: BomProcessVersionEditLine[] | null; +} + export interface EditBomRequest { // basic fields description?: string; @@ -144,6 +305,7 @@ export interface EditBomRequest { isDrink?: boolean; isPowderMixture?: boolean; status?: BomStatus; + putawayLocationCode?: string | null; materials?: EditBomMaterialRequest[]; processes?: EditBomProcessRequest[]; diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 21f5506..c4d4bc7 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -285,14 +285,14 @@ export interface ProductProcessWithLinesResponse { jobOrderStatus: string; bomDescription: string; jobType: string; - isDark: string; + isDark: number | null; bomBaseQty: number; - isDense: number; - isFloat: string; - timeSequence: number; - complexity: number; + isDense: number | null; + isFloat: number | null; + timeSequence: number | null; + complexity: number | null; scrapRate: number; - allergicSubstance: string; + allergicSubstance: number | null; itemId: number; itemCode: string; itemName: string; diff --git a/src/app/api/masterDataIssues/bomUomAlignment.ts b/src/app/api/masterDataIssues/bomUomAlignment.ts new file mode 100644 index 0000000..7b38f8a --- /dev/null +++ b/src/app/api/masterDataIssues/bomUomAlignment.ts @@ -0,0 +1,97 @@ +export interface BomUomLabel { + uomId?: number | null; + code?: string | null; + udfudesc?: string | null; + label?: string | null; +} + +export interface BomUomQtyValue { + qty?: number | string | null; + uom?: BomUomLabel | null; +} + +export interface BomMaterialUomOption { + uomId: number; + label: string; + code?: string | null; +} + +export interface BomHeaderUomAlignmentPreview { + bomId: number; + bomCode?: string | null; + bomName?: string | null; + itemId?: number | null; + canApply: boolean; + skipReason?: string | null; + beforeOutputQty?: number | string | null; + afterOutputQty?: number | string | null; + beforeOutputQtyStock?: number | string | null; + afterOutputQtyStock?: number | string | null; + beforeUom?: BomUomLabel | null; + afterUom?: BomUomLabel | null; + beforeBaseUom?: BomUomLabel | null; + afterBaseUom?: BomUomLabel | null; + beforeStockUom?: BomUomLabel | null; + afterStockUom?: BomUomLabel | null; +} + +export interface BomMaterialUomAlignmentPreview { + bomId: number; + bomCode?: string | null; + bomName?: string | null; + bomMaterialId: number; + itemId?: number | null; + itemCode?: string | null; + itemName?: string | null; + canApply: boolean; + skipReason?: string | null; + warnings?: string[]; + beforeSaleQty?: BomUomQtyValue | null; + afterSaleQty?: BomUomQtyValue | null; + beforeStockQty?: BomUomQtyValue | null; + afterStockQty?: BomUomQtyValue | null; + beforeBaseQty?: BomUomQtyValue | null; + afterBaseQty?: BomUomQtyValue | null; + beforeRecipeQty?: BomUomQtyValue | null; + afterRecipeQty?: BomUomQtyValue | null; + availableRecipeUoms?: BomMaterialUomOption[]; + suggestedRecipeUomId?: number | null; +} + +export interface BomUomAlignmentSkipped { + bomId?: number | null; + bomCode?: string | null; + bomMaterialId?: number | null; + itemCode?: string | null; + reason: string; +} + +export interface BomUomAlignmentPreviewResponse { + headers: BomHeaderUomAlignmentPreview[]; + materials: BomMaterialUomAlignmentPreview[]; + skipped: BomUomAlignmentSkipped[]; +} + +export type BomMaterialQtyAnchor = "SALE_QTY" | "STOCK_QTY" | "BASE_QTY" | "RECIPE_QTY"; + +export interface BomMaterialQtyRecalculateResponse { + saleQty?: number | string | null; + stockQty?: number | string | null; + baseQty?: number | string | null; + saleUom?: string | null; + stockUom?: string | null; + baseUom?: string | null; +} + +export interface BomHeaderOutputQtyRecalculateResponse { + stockQty?: number | string | null; + baseQty?: number | string | null; + baseUom?: string | null; + baseUomId?: number | null; +} + +export interface BomUomAlignmentApplyResponse { + headersUpdated: number; + materialsUpdated: number; + message?: string | null; +} diff --git a/src/app/api/masterDataIssues/client.ts b/src/app/api/masterDataIssues/client.ts index c9a589b..6ab13a8 100644 --- a/src/app/api/masterDataIssues/client.ts +++ b/src/app/api/masterDataIssues/client.ts @@ -3,6 +3,13 @@ import axiosInstance from "@/app/(main)/axios/axiosInstance"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import type { MasterDataIssue, MasterDataIssueSummary } from "./index"; +import type { + BomMaterialQtyAnchor, + BomMaterialQtyRecalculateResponse, + BomHeaderOutputQtyRecalculateResponse, + BomUomAlignmentApplyResponse, + BomUomAlignmentPreviewResponse, +} from "./bomUomAlignment"; export async function fetchBomMasterDataIssuesClient(): Promise { const response = await axiosInstance.get( @@ -27,3 +34,72 @@ export async function fetchMasterDataIssuesSummaryClient( ); return response.data; } + +export async function previewBomUomAlignmentClient(params?: { + bomIds?: number[]; + bomMaterialIds?: number[]; +}): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/master-data/uom-alignment/preview`, + { + bomIds: params?.bomIds?.length ? params.bomIds : null, + bomMaterialIds: params?.bomMaterialIds?.length ? params.bomMaterialIds : null, + }, + ); + return response.data; +} + +export async function recalculateBomMaterialQtyClient(params: { + itemId: number; + anchor: BomMaterialQtyAnchor; + qty: number; + salesUomId: number; + stockUomId: number; + baseUomId: number; + recipeUomId?: number; +}): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/master-data/uom-alignment/recalculate`, + params, + ); + return response.data; +} + +export async function recalculateBomHeaderOutputQtyClient(params: { + itemId: number; + stockQty: number; + stockUomId: number; +}): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/master-data/uom-alignment/recalculate-header`, + params, + ); + return response.data; +} + +export async function applyBomUomAlignmentClient(body: { + headers: Array<{ + bomId: number; + uomId: number; + outputQty?: number | null; + outputQtyStock?: number | null; + stockUomId?: number | null; + }>; + materials: Array<{ + bomMaterialId: number; + saleQty?: number | null; + salesUomId?: number | null; + stockQty?: number | null; + stockUomId?: number | null; + baseQty?: number | null; + baseUomId?: number | null; + qty?: number | null; + uomId?: number | null; + }>; +}): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/master-data/uom-alignment/apply`, + body, + ); + return response.data; +} diff --git a/src/app/api/masterDataIssues/index.ts b/src/app/api/masterDataIssues/index.ts index 9eddc48..a5121da 100644 --- a/src/app/api/masterDataIssues/index.ts +++ b/src/app/api/masterDataIssues/index.ts @@ -26,8 +26,16 @@ export type MasterDataIssueCode = | "MULTIPLE_PICKING_UOM" | "MULTIPLE_PURCHASE_UOM" | "BOM_OUTPUT_UOM_MISMATCH_SALES" + | "BOM_OUTPUT_UOM_MISMATCH_STOCK" + | "BOM_OUTPUT_UOM_MISMATCH_BASE" | "BOM_OUTPUT_UOM_TEXT_DRIFT" | "BOM_MATERIAL_MISSING_ITEM" + | "BOM_MATERIAL_MISSING_QTY" + | "BOM_MATERIAL_MISSING_RECIPE_UOM" + | "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" + | "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" + | "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX" + | "BOM_MATERIAL_DERIVE_FAILED" | "BOM_MATERIAL_SALES_UOM_MISMATCH" | "BOM_MATERIAL_BASE_UOM_MISMATCH" | "BOM_MATERIAL_STOCK_UOM_MISMATCH" diff --git a/src/components/ImportBom/BomAttributeScaleDisplay.tsx b/src/components/ImportBom/BomAttributeScaleDisplay.tsx new file mode 100644 index 0000000..edef251 --- /dev/null +++ b/src/components/ImportBom/BomAttributeScaleDisplay.tsx @@ -0,0 +1,543 @@ +"use client"; + +import React from "react"; +import { Box, FormControl, InputLabel, MenuItem, Select, Slider, Stack, Typography } from "@mui/material"; +import { + type AttributeScaleConfig, + type BomScaleField, + BOM_SCALE_ALLOWED, + captionForAllowedScaleValue, + allowedStepsForField, + coerceBomScaleNumber, + isNaScaleValue, + normalizeScaleValue, + SCALE_RAIL_BG, + SCALE_THUMB_BORDER, + scaleCaption, + scaleSliderSx, + snapToAllowedScale, + discreteStepLabel, +} from "./bomAttributeScales"; +import { + COMPARE_NEW_COLOR, + COMPARE_OLD_COLOR, +} from "./bomVersionCompare"; + +const THUMB_SIZE = 22; +const THUMB_SIZE_COMPACT = 18; +const RAIL_HEIGHT = 10; +const RAIL_HEIGHT_COMPACT = 8; + +function markerLeftPercent(value: number, railMin: number, railMax: number): number { + const span = railMax - railMin; + if (span <= 0) return 0; + return ((value - railMin) / span) * 100; +} + +function resolveDisplayScaleValue( + value: number | null | undefined, + config: AttributeScaleConfig, + steps: readonly number[] | null, +): number | null { + const n = coerceBomScaleNumber(value); + if (n == null) return null; + if (steps?.includes(n)) return n; + return normalizeScaleValue(value, config); +} + +type ScaleMarkerProps = { + value: number; + railMin: number; + railMax: number; + variant: "single" | "old" | "new"; + compact?: boolean; +}; + +function ScaleMarker({ value, railMin, railMax, variant, compact = false }: ScaleMarkerProps) { + const size = compact ? THUMB_SIZE_COMPACT : THUMB_SIZE; + const borderColor = + variant === "old" + ? COMPARE_OLD_COLOR.border + : variant === "new" + ? COMPARE_NEW_COLOR.border + : SCALE_THUMB_BORDER; + + return ( + + ); +} + +type ScaleTrackProps = { + compact?: boolean; + children?: React.ReactNode; +}; + +function ScaleTrack({ compact = false, children }: ScaleTrackProps) { + const railHeight = compact ? RAIL_HEIGHT_COMPACT : RAIL_HEIGHT; + const thumbPad = compact ? THUMB_SIZE_COMPACT / 2 : THUMB_SIZE / 2; + + return ( + + + + {children} + + + ); +} + +type ScaleBarProps = { + label: string; + config: AttributeScaleConfig; + value: number | null | undefined; + compact?: boolean; + readOnly?: boolean; + onChange?: (value: number) => void; + showFieldLabel?: boolean; + allowedSteps?: readonly number[]; +}; + +function ScaleBar({ + label, + config, + value, + compact = false, + readOnly = true, + onChange, + showFieldLabel = true, + allowedSteps, +}: ScaleBarProps) { + const steps = allowedSteps?.length ? [...allowedSteps] : null; + const railMin = steps ? Math.min(...steps) : config.min; + const railMax = steps ? Math.max(...steps) : config.max; + + const numericValue = + typeof value === "number" && !Number.isNaN(value) ? value : config.naValue ?? 0; + + const sliderValue = steps + ? Math.min(railMax, Math.max(railMin, numericValue)) + : (normalizeScaleValue(value, config) ?? config.min); + + const captionValue = steps ? numericValue : (normalizeScaleValue(value, config) ?? sliderValue); + + if (!steps && normalizeScaleValue(value, config) == null && readOnly) { + return ( + + {showFieldLabel && ( + + {label} + + )} + + 不適用 + + + ); + } + + return ( + + + {showFieldLabel ? ( + + {label} + + ) : ( + + )} + + {scaleCaption(captionValue, config)} + + + + ({ + value: step, + })) + : undefined + } + value={sliderValue} + disabled={readOnly} + onChange={ + readOnly || !onChange + ? undefined + : (_, v) => { + const raw = v as number; + onChange(steps ? snapToAllowedScale(raw, steps) : raw); + } + } + sx={scaleSliderSx(config, compact, Boolean(steps?.length))} + /> + + {steps && steps.length > 0 ? ( + + {steps.map((step) => ( + + {discreteStepLabel(step, config)} + + ))} + + ) : ( + + + {config.lowLabel} + + + {config.highLabel} + + + )} + + ); +} + +type CompareScaleBarProps = { + label: string; + config: AttributeScaleConfig; + oldValue: number; + newValue: number; + compact?: boolean; + allowedSteps?: readonly number[]; +}; + +function CompareScaleBar({ + label, + config, + oldValue, + newValue, + compact = false, + allowedSteps, +}: CompareScaleBarProps) { + const steps = allowedSteps?.length ? [...allowedSteps] : null; + const railMin = steps ? Math.min(...steps) : config.min; + const railMax = steps ? Math.max(...steps) : config.max; + const oldCaption = scaleCaption(oldValue, config); + const newCaption = scaleCaption(newValue, config); + + return ( + + + + {label} + + + + {oldCaption} + + + {newCaption} + + + + + + + + + + {steps && steps.length > 0 ? ( + + {steps.map((step) => ( + + {discreteStepLabel(step, config)} + + ))} + + ) : ( + + + {config.lowLabel} + + + {config.highLabel} + + + )} + + ); +} + +type DisplayProps = { + label: string; + config: AttributeScaleConfig; + field?: BomScaleField; + allowedSteps?: readonly number[]; + value: number | null | undefined; + compareOldValue?: number | null; + comparing?: boolean; +}; + +export function BomAttributeScaleDisplay({ + label, + config, + field, + allowedSteps: allowedStepsProp, + value, + compareOldValue, + comparing = false, +}: DisplayProps) { + const allowedSteps = allowedStepsProp ?? (field ? allowedStepsForField(field) : null); + const displayValue = resolveDisplayScaleValue(value, config, allowedSteps); + const displayOld = resolveDisplayScaleValue(compareOldValue, config, allowedSteps); + const showCompare = + comparing && + displayOld != null && + displayValue != null && + displayOld !== displayValue; + + if (showCompare) { + return ( + + ); + } + + return ( + + ); +} + +type SliderFieldProps = { + label: string; + config: AttributeScaleConfig; + value: number; + onChange: (value: number) => void; + allowNa?: boolean; + compact?: boolean; + allowedSteps?: readonly number[]; +}; + +export function BomAttributeScaleSlider({ + label, + config, + value, + onChange, + allowNa = false, + compact = false, + allowedSteps, +}: SliderFieldProps) { + const stepsIncludeZero = allowedSteps?.includes(0) ?? false; + const isNa = + stepsIncludeZero + ? false + : allowedSteps?.length + ? value === 0 && allowNa + : isNaScaleValue(value, config.naValue); + + const firstNonZeroStep = + allowedSteps?.find((step) => step !== 0) ?? config.min; + + const showNaToggle = allowNa && !stepsIncludeZero; + + return ( + + {showNaToggle && ( + + onChange(isNa ? firstNonZeroStep : 0)} + sx={{ + border: "none", + background: "none", + cursor: "pointer", + color: isNa ? "primary.main" : "text.secondary", + textDecoration: "underline", + }} + > + {isNa ? "設定刻度" : "不適用"} + + + )} + {isNa ? ( + <> + + {label} + + + 不適用 + + + ) : ( + + )} + + ); +} + +type DiscreteSelectProps = { + label: string; + field: BomScaleField; + value: number; + onChange: (value: number) => void; + compact?: boolean; + error?: boolean; + helperText?: string; + includeNa?: boolean; +}; + +const INVALID_SCALE_SENTINEL = -999; + +export function BomAttributeDiscreteSelect({ + label, + field, + value, + onChange, + compact = false, + error = false, + helperText, + includeNa = field !== "allergicSubstances", +}: DiscreteSelectProps) { + const options = BOM_SCALE_ALLOWED[field].filter( + (v) => includeNa || v !== 0, + ); + const isValid = options.includes(value); + const selectValue = isValid ? value : INVALID_SCALE_SENTINEL; + + return ( + + {label} + + {(helperText || !isValid) ? ( + + {helperText ?? `${label}數值無效,請重選`} + + ) : null} + + ); +} diff --git a/src/components/ImportBom/BomBasicInfoSection.tsx b/src/components/ImportBom/BomBasicInfoSection.tsx new file mode 100644 index 0000000..6a812df --- /dev/null +++ b/src/components/ImportBom/BomBasicInfoSection.tsx @@ -0,0 +1,797 @@ +"use client"; + +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { + Button, + FormControl, + Grid, + InputLabel, + MenuItem, + OutlinedInput, + Paper, + Select, + Stack, + Typography, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import CancelIcon from "@mui/icons-material/Cancel"; +import { useTranslation } from "react-i18next"; +import type { BomDetailResponse, BomStatus } from "@/app/api/bom"; +import { + activateBomVersionClient, + editBomClient, +} from "@/app/api/bom/client"; +import { + BomAttributeScaleDisplay, + BomAttributeScaleSlider, +} from "./BomAttributeScaleDisplay"; +import { + COLOR_DEPTH_SCALE, + COMPLEXITY_SCALE, + DENSITY_SCALE, + FLOAT_SINK_SCALE, + allowedStepsForField, + scaleValueForEdit, + scaleValueForEditDiscrete, + validateScaleAllowedForEdit, +} from "./bomAttributeScales"; +import { DiffValue } from "./bomDiff"; +import { BomPutawayLocationFields } from "./BomPutawayLocationFields"; +import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; +import { recalculateBomHeaderOutputQtyClient } from "@/app/api/masterDataIssues/client"; +import { + buildPutawayLocationCode, + formatPutawayLocationDisplay, + parsePutawayLocationCode, + type PutawayLocationParts, + validatePutawayLocationParts, +} from "./putawayLocationUtils"; + +export type BasicInfoDraft = { + bomKind: "FG" | "WIP"; + outputQty: number; + outputQtyUom: string; + status: BomStatus; + allergicSubstances: number; + timeSequence: number; + isDark: number; + isFloat: number; + isDense: number; + complexity: number; + putawayLocation: PutawayLocationParts; +}; + +function normalizeBomKind(v?: string | null): "FG" | "WIP" { + const k = (v ?? "FG").trim().toUpperCase(); + return k === "WIP" ? "WIP" : "FG"; +} + +function renderBomKindLabel(v?: string | null): string { + const kind = normalizeBomKind(v); + if (kind === "FG") return "成品"; + if (kind === "WIP") return "半成品"; + return "-"; +} + +export function draftFromDetail(detail: BomDetailResponse): BasicInfoDraft { + const initialPutaway = + detail.putawayLocationCode?.trim() || + detail.itemLocationCode?.trim() || + ""; + return { + bomKind: normalizeBomKind(detail.bomKind ?? detail.description), + outputQty: detail.outputQty ?? 0, + outputQtyUom: detail.outputQtyUom ?? "", + status: detail.status ?? "active", + allergicSubstances: detail.allergicSubstances ?? 0, + timeSequence: scaleValueForEditDiscrete(detail.timeSequence, "timeSequence"), + isDark: scaleValueForEdit(detail.isDark, COLOR_DEPTH_SCALE), + isFloat: scaleValueForEditDiscrete(detail.isFloat, "isFloat"), + isDense: scaleValueForEditDiscrete(detail.isDense, "isDense"), + complexity: scaleValueForEditDiscrete(detail.complexity, "complexity"), + putawayLocation: parsePutawayLocationCode(initialPutaway), + }; +} + +function putawayCodeFromDetail(detail: BomDetailResponse): string { + return buildPutawayLocationCode( + parsePutawayLocationCode(detail.putawayLocationCode ?? ""), + ); +} + +function draftMatchesDetailExcludingPutaway( + draft: BasicInfoDraft, + detail: BomDetailResponse, +): boolean { + const baseline = draftFromDetail(detail); + return ( + draft.bomKind === baseline.bomKind && + draft.outputQty === baseline.outputQty && + draft.outputQtyUom === baseline.outputQtyUom && + draft.status === baseline.status && + draft.allergicSubstances === baseline.allergicSubstances && + draft.timeSequence === baseline.timeSequence && + draft.isDark === baseline.isDark && + draft.isFloat === baseline.isFloat && + draft.isDense === baseline.isDense && + draft.complexity === baseline.complexity + ); +} + +function draftMatchesDetail( + draft: BasicInfoDraft, + detail: BomDetailResponse, +): boolean { + return ( + draftMatchesDetailExcludingPutaway(draft, detail) && + buildPutawayLocationCode(draft.putawayLocation) === putawayCodeFromDetail(detail) + ); +} + +export function hasPutawayLocationChanges( + draft: BasicInfoDraft, + detail: BomDetailResponse, +): boolean { + if (draft.bomKind !== "FG") return false; + return ( + buildPutawayLocationCode(draft.putawayLocation) !== + putawayCodeFromDetail(detail) + ); +} + +export function validateBasicDraft( + draft: BasicInfoDraft, + warehouseCodes: ReadonlySet, + putawayMissingErr?: string, +): string | null { + if ( + draft.outputQty == null || + Number.isNaN(draft.outputQty) || + draft.outputQty <= 0 + ) { + return "產出數量必須大於 0"; + } + if (draft.bomKind === "FG") { + const parts = draft.putawayLocation; + const values = [ + parts.floor, + parts.warehouse, + parts.area, + parts.slot, + ]; + const allEmpty = values.every((v) => v.trim().length === 0); + + const putawayErr = validatePutawayLocationParts( + draft.putawayLocation, + warehouseCodes, + ); + if (putawayErr) return putawayErr; + if (allEmpty) return putawayMissingErr ?? "FG 上架位置未設定"; + } + return ( + validateScaleAllowedForEdit(draft.isDark, "isDark") ?? + validateScaleAllowedForEdit(draft.isFloat, "isFloat") ?? + validateScaleAllowedForEdit(draft.isDense, "isDense") ?? + validateScaleAllowedForEdit(draft.timeSequence, "timeSequence") ?? + validateScaleAllowedForEdit(draft.complexity, "complexity") ?? + validateScaleAllowedForEdit(draft.allergicSubstances, "allergicSubstances") + ); +} + +export async function saveBasicInfoDraft( + bomId: number, + detail: BomDetailResponse, + draft: BasicInfoDraft, +): Promise { + const wasActive = (detail.status ?? "active") === "active"; + const payload = { + description: draft.bomKind, + outputQty: draft.outputQty, + outputQtyUom: draft.outputQtyUom || undefined, + allergicSubstances: draft.allergicSubstances, + timeSequence: draft.timeSequence, + isDark: draft.isDark, + isFloat: draft.isFloat, + isDense: draft.isDense, + complexity: draft.complexity, + ...(draft.status === "inactive" ? { status: draft.status } : {}), + }; + + let updated = await editBomClient(bomId, payload); + if (draft.status === "active" && !wasActive) { + updated = await activateBomVersionClient(bomId); + } + return updated; +} + +export type BomBasicInfoSectionHandle = { + isEditing: () => boolean; + hasBasicChanges: () => boolean; + hasPutawayChanges: () => boolean; + validate: () => string | null; + getDraft: () => BasicInfoDraft | null; + getPutawayLocationCodeForSave: () => string | undefined; + cancelEdit: () => void; +}; + +type Props = { + detail: BomDetailResponse; + compareOldDetail?: BomDetailResponse | null; + comparing?: boolean; + editDisabled?: boolean; + onDirtyChange?: (dirty: boolean) => void; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-16 */ +export const BomBasicInfoSection = forwardRef( + function BomBasicInfoSection( + { + detail, + compareOldDetail, + comparing = false, + editDisabled = false, + onDirtyChange, + }, + ref, + ) { + const { t } = useTranslation("importBom"); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(null); + const [outputQtyStockInput, setOutputQtyStockInput] = useState(""); + const [outputQtyStockLoading, setOutputQtyStockLoading] = useState(false); + const [outputQtyStockError, setOutputQtyStockError] = useState(null); + const outputQtyStockInFlightRef = useRef(false); + const [warehouseCodes, setWarehouseCodes] = useState>( + new Set(), + ); + + useEffect(() => { + setIsEditing(false); + setDraft(null); + setOutputQtyStockInput(""); + setOutputQtyStockLoading(false); + setOutputQtyStockError(null); + outputQtyStockInFlightRef.current = false; + }, [detail.id]); + + useEffect(() => { + if (!isEditing) return; + let cancelled = false; + fetchWarehouseListClient() + .then((list) => { + if (cancelled) return; + setWarehouseCodes( + new Set( + list + .map((w) => (w.code ?? "").trim()) + .filter((code) => code.length > 0), + ), + ); + }) + .catch(() => { + if (!cancelled) setWarehouseCodes(new Set()); + }); + return () => { + cancelled = true; + }; + }, [isEditing]); + + useEffect(() => { + if (!isEditing || !draft) { + onDirtyChange?.(false); + return; + } + onDirtyChange?.(!draftMatchesDetail(draft, detail)); + }, [draft, detail, isEditing, onDirtyChange]); + + const startEdit = useCallback(() => { + setDraft(draftFromDetail(detail)); + setOutputQtyStockInput( + detail.outputQtyStock == null ? "" : String(detail.outputQtyStock), + ); + setOutputQtyStockError(null); + setIsEditing(true); + }, [detail]); + + const cancelEdit = useCallback(() => { + setIsEditing(false); + setDraft(null); + setOutputQtyStockInput(""); + setOutputQtyStockLoading(false); + setOutputQtyStockError(null); + outputQtyStockInFlightRef.current = false; + onDirtyChange?.(false); + }, [onDirtyChange]); + + const syncBaseQtyFromOutputQty = useCallback(async () => { + if (!isEditing || !draft) return; + const stockUomId = detail.outputQtyStockUomId; + const itemId = detail.itemId; + if (!stockUomId || !itemId) return; + const stockQty = Number(outputQtyStockInput); + if (!outputQtyStockInput.trim() || Number.isNaN(stockQty) || stockQty <= 0) { + setOutputQtyStockError(t("Output Quantity") + "必須大於 0"); + return; + } + if (outputQtyStockInFlightRef.current) return; + outputQtyStockInFlightRef.current = true; + setOutputQtyStockLoading(true); + setOutputQtyStockError(null); + try { + const result = await recalculateBomHeaderOutputQtyClient({ + itemId, + stockQty, + stockUomId, + }); + const nextBaseQty = Number(result.baseQty); + const nextBaseUom = (result.baseUom ?? detail.outputQtyUom ?? draft.outputQtyUom ?? "").trim(); + if (Number.isNaN(nextBaseQty) || nextBaseQty <= 0) { + setOutputQtyStockError("無法換算基本數量"); + return; + } + setDraft((p) => + p + ? { + ...p, + outputQty: nextBaseQty, + outputQtyUom: nextBaseUom, + } + : p, + ); + } catch (error) { + setOutputQtyStockError( + error instanceof Error ? error.message : "換算基本數量失敗", + ); + } finally { + setOutputQtyStockLoading(false); + outputQtyStockInFlightRef.current = false; + } + }, [detail.itemId, detail.outputQtyStockUomId, draft, isEditing, outputQtyStockInput, t]); + + useImperativeHandle( + ref, + () => ({ + isEditing: () => isEditing, + hasBasicChanges: () => + isEditing && + draft != null && + !draftMatchesDetailExcludingPutaway(draft, detail), + hasPutawayChanges: () => + isEditing && draft != null && hasPutawayLocationChanges(draft, detail), + validate: () => { + if (!isEditing || !draft) return null; + if (outputQtyStockError) return outputQtyStockError; + if (draftMatchesDetail(draft, detail)) return null; + return validateBasicDraft( + draft, + warehouseCodes, + t("bomPutawayLocation_missing_warn"), + ); + }, + getDraft: () => draft, + getPutawayLocationCodeForSave: () => { + if (!draft || draft.bomKind !== "FG") return undefined; + return buildPutawayLocationCode(draft.putawayLocation) || ""; + }, + cancelEdit, + }), + [cancelEdit, detail, draft, isEditing, outputQtyStockError, warehouseCodes], + ); + + const isFg = normalizeBomKind(detail.bomKind ?? detail.description) === "FG"; + const headerOutputQtyIssue = + detail.outputQtyStockConvertible === false && + detail.outputQtyStockUom != null; + + const renderAllergic = (v?: number) => { + if (v === 0) return "有"; + if (v === 5) return "沒有"; + return "-"; + }; + + const renderTimeSequence = (v?: number) => { + if (v === 5) return "上午"; + if (v === 1) return "下午"; + if (v === 0) return "不適用"; + return "-"; + }; + + const renderBomStatus = (v?: BomStatus | string) => { + if (v === "active") return t("BOM Status Active"); + if (v === "inactive") return t("BOM Status Inactive"); + return "-"; + }; + + const diffField = (oldVal: string, newVal: string) => ( + + ); + + const stockQtyText = (d?: BomDetailResponse | null): string => { + if (!d) return "-"; + if (d.outputQtyStockConvertible === false || d.outputQtyStock == null) { + return `--- ${d.outputQtyStockUom ?? "-"}`.trim(); + } + return `${d.outputQtyStock} ${d.outputQtyStockUom ?? ""}`.trim(); + }; + + const baseQtyText = (d?: BomDetailResponse | null): string => + `${d?.outputQty ?? "-"} ${d?.outputQtyUom ?? ""}`.trim(); + + return ( + + + {t("Basic Info")} + + {!isEditing && !comparing ? ( + + ) : isEditing ? ( + + ) : null} + + + {!isEditing && ( + + + {t("Output Quantity")}:{" "} + {diffField( + stockQtyText(compareOldDetail), + stockQtyText(detail), + )} + {" "} + {t("Base Qty")}:{" "} + {diffField( + baseQtyText(compareOldDetail), + baseQtyText(detail), + )} + {" "} + {t("Type")}: {renderBomKindLabel(detail.bomKind ?? detail.description)} + {" "} + {t("BOM Revision")}:{" "} + {diffField( + `V${compareOldDetail?.revisionNo ?? 1}`, + `V${detail.revisionNo ?? 1}`, + )} + {" "} + {t("BOM Status")}:{" "} + {diffField( + renderBomStatus(compareOldDetail?.status), + renderBomStatus(detail.status), + )} + + + {headerOutputQtyIssue && ( + + {t("bomHeaderOutputQtyStockConvertFail_warn")} + + )} + + + {t("Allergic Substances")}:{" "} + {diffField( + renderAllergic(compareOldDetail?.allergicSubstances), + renderAllergic(detail.allergicSubstances), + )} + {" "} + {t("Time Sequence")}:{" "} + {diffField( + renderTimeSequence(compareOldDetail?.timeSequence), + renderTimeSequence(detail.timeSequence), + )} + {" "} + {t("Base Score")}:{" "} + {diffField( + String(compareOldDetail?.baseScore ?? "-"), + String(detail.baseScore ?? "-"), + )} + + + {isFg && ( + + {t("Putaway Location")}:{" "} + {diffField( + formatPutawayLocationDisplay(compareOldDetail?.putawayLocationCode), + formatPutawayLocationDisplay(detail.putawayLocationCode), + )} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {isEditing && draft && ( + + + + {t("Type")} + + + + + + {t("Output Quantity")} + + { + const next = e.target.value; + setOutputQtyStockInput(next); + setOutputQtyStockError(null); + if (!next.trim()) { + // Clearing stock input should reset derived base fields, + // otherwise stale converted values keep the draft "dirty". + setDraft((p) => + p + ? { + ...p, + outputQty: detail.outputQty ?? 0, + outputQtyUom: detail.outputQtyUom ?? "", + } + : p, + ); + } + }} + onBlur={() => { + void syncBaseQtyFromOutputQty(); + }} + inputProps={{ min: 0, step: "any" }} + /> + + + + {detail.outputQtyStockUom ?? "-"} + + + {headerOutputQtyIssue && ( + + {t("bomHeaderOutputQtyStockConvertFail_warn")} + + )} + + + {t("Base Qty")}: {draft.outputQty ?? "-"} {draft.outputQtyUom ?? ""} + {outputQtyStockLoading ? "(換算中)" : ""} + + + + {t("BOM Status")} + + + + + {t("Allergic Substances")} + + + + + {t("Time Sequence")} + + + + + {t("Base Score")}: {detail.baseScore ?? "-"} + + + + {draft.bomKind === "FG" && ( + + + {t("Putaway Location")} + + + setDraft((p) => (p ? { ...p, putawayLocation } : p)) + } + error={validatePutawayLocationParts( + draft.putawayLocation, + warehouseCodes, + )} + /> + + {t("Putaway edit creates new version hint")} + + + )} + + + + + + setDraft((p) => (p ? { ...p, isDark: v } : p)) + } + /> + + + + + + setDraft((p) => (p ? { ...p, isFloat: v } : p)) + } + /> + + + + + + setDraft((p) => (p ? { ...p, isDense: v } : p)) + } + /> + + + + + + setDraft((p) => (p ? { ...p, complexity: v } : p)) + } + /> + + + + + )} + + ); + }, +); diff --git a/src/components/ImportBom/BomImportMaterialEditTable.tsx b/src/components/ImportBom/BomImportMaterialEditTable.tsx new file mode 100644 index 0000000..a6fe1f7 --- /dev/null +++ b/src/components/ImportBom/BomImportMaterialEditTable.tsx @@ -0,0 +1,437 @@ +"use client"; + +import React, { useCallback, useEffect, useRef } from "react"; +import { + FormControl, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { fetchImportBomMaterialItemContextClient } from "@/app/api/bom/client"; +import { recalculateBomMaterialQtyClient } from "@/app/api/masterDataIssues/client"; +import { fmtQtyUom } from "./bomDiff"; +import type { MaterialRowFormatErrors } from "./bomImportFormatFieldErrors"; +import { firstFieldError, hasFieldError } from "./bomImportFormatFieldErrors"; +import type { ImportMaterialEditRow } from "./bomImportPreviewUtils"; + +export type ImportProcessStepOption = { + seqNo: number; + label: string; +}; + +type Props = { + rows: ImportMaterialEditRow[]; + processStepOptions: ImportProcessStepOption[]; + formatErrorsByRowKey?: Record; + onRowsChange: React.Dispatch>; +}; + +function pickRecipeUomId( + available: ImportMaterialEditRow["availableRecipeUoms"], + preferredUomId?: number, + preferredUomCode?: string, +): number { + if (preferredUomId && available.some((u) => u.uomId === preferredUomId)) { + return preferredUomId; + } + const code = (preferredUomCode ?? "").trim().toUpperCase(); + if (code) { + const byCode = available.find( + (u) => + (u.code ?? "").trim().toUpperCase() === code || + (u.label ?? "").trim().toUpperCase() === code, + ); + if (byCode) return byCode.uomId; + } + return available[0]?.uomId ?? 0; +} + +export default function BomImportMaterialEditTable({ + rows, + processStepOptions, + formatErrorsByRowKey, + onRowsChange, +}: Props) { + const { t } = useTranslation("importBom"); + const previewTimersRef = useRef>>({}); + const itemCodeTimersRef = useRef>>({}); + const itemCodeReqSeqRef = useRef>({}); + + const schedulePreview = useCallback( + (key: string, row: ImportMaterialEditRow) => { + if (previewTimersRef.current[key]) { + clearTimeout(previewTimersRef.current[key]); + } + previewTimersRef.current[key] = setTimeout(async () => { + if ( + !row.itemId || + row.salesUomId == null || + row.stockUomId == null || + row.baseUomId == null || + !row.uomId + ) { + return; + } + try { + const result = await recalculateBomMaterialQtyClient({ + itemId: row.itemId, + anchor: "RECIPE_QTY", + qty: row.qty, + salesUomId: row.salesUomId, + stockUomId: row.stockUomId, + baseUomId: row.baseUomId, + recipeUomId: row.uomId, + }); + onRowsChange((prev) => + prev.map((r) => + r.key === key + ? { + ...r, + previewBaseQty: result.baseQty ?? undefined, + previewStockQty: result.stockQty ?? undefined, + baseUom: result.baseUom ?? r.baseUom, + stockUom: result.stockUom ?? r.stockUom, + } + : r, + ), + ); + } catch { + // keep previous preview + } + }, 300); + }, + [onRowsChange], + ); + + const resolveItemCode = useCallback( + (key: string, itemCode: string, currentUomId?: number, currentUomCode?: string) => { + if (itemCodeTimersRef.current[key]) { + clearTimeout(itemCodeTimersRef.current[key]); + } + itemCodeTimersRef.current[key] = setTimeout(async () => { + const trimmed = itemCode.trim(); + const seq = (itemCodeReqSeqRef.current[key] ?? 0) + 1; + itemCodeReqSeqRef.current[key] = seq; + if (!trimmed) { + onRowsChange((prev) => + prev.map((r) => + r.key === key + ? { + ...r, + itemCode: "", + itemName: "", + itemId: undefined, + salesUomId: undefined, + stockUomId: undefined, + baseUomId: undefined, + availableRecipeUoms: [], + uomId: 0, + previewBaseQty: undefined, + previewStockQty: undefined, + baseUom: undefined, + stockUom: undefined, + } + : r, + ), + ); + return; + } + try { + const ctx = await fetchImportBomMaterialItemContextClient(trimmed, { + currentUomId, + currentUomCode, + }); + if (itemCodeReqSeqRef.current[key] !== seq) return; + onRowsChange((prev) => { + const next = prev.map((r) => { + if (r.key !== key) return r; + if (!ctx.exists || !ctx.itemId) { + return { + ...r, + itemCode: trimmed, + itemName: "", + itemId: undefined, + salesUomId: undefined, + stockUomId: undefined, + baseUomId: undefined, + availableRecipeUoms: [], + previewBaseQty: undefined, + previewStockQty: undefined, + baseUom: undefined, + stockUom: undefined, + }; + } + const available = (ctx.availableRecipeUoms ?? []).map((u) => ({ + uomId: u.uomId, + label: u.label, + code: u.code ?? undefined, + })); + const uomId = pickRecipeUomId(available, r.uomId, r.uomCode); + const uomCode = + available.find((u) => u.uomId === uomId)?.code ?? r.uomCode; + return { + ...r, + itemCode: ctx.itemCode || trimmed, + itemName: ctx.itemName?.trim() || "", + itemId: ctx.itemId, + salesUomId: ctx.salesUomId ?? undefined, + stockUomId: ctx.stockUomId ?? undefined, + baseUomId: ctx.baseUomId ?? undefined, + availableRecipeUoms: available, + uomId, + uomCode, + previewBaseQty: undefined, + previewStockQty: undefined, + }; + }); + const updated = next.find((r) => r.key === key); + if (updated) schedulePreview(key, updated); + return next; + }); + } catch { + if (itemCodeReqSeqRef.current[key] !== seq) return; + onRowsChange((prev) => + prev.map((r) => + r.key === key + ? { + ...r, + itemCode: trimmed, + itemName: "", + itemId: undefined, + salesUomId: undefined, + stockUomId: undefined, + baseUomId: undefined, + availableRecipeUoms: [], + previewBaseQty: undefined, + previewStockQty: undefined, + baseUom: undefined, + stockUom: undefined, + } + : r, + ), + ); + } + }, 400); + }, + [onRowsChange, schedulePreview], + ); + + const updateRow = useCallback( + (key: string, patch: Partial) => { + onRowsChange((prev) => { + const next = prev.map((r) => { + if (r.key !== key) return r; + const updated = { ...r, ...patch }; + if (patch.uomId != null) { + const opt = updated.availableRecipeUoms.find( + (u) => u.uomId === patch.uomId, + ); + updated.uomCode = opt?.code ?? updated.uomCode; + } + return updated; + }); + const updated = next.find((r) => r.key === key); + if (updated && patch.itemCode == null) { + schedulePreview(key, updated); + } + return next; + }); + }, + [onRowsChange, schedulePreview], + ); + + useEffect(() => { + return () => { + Object.values(previewTimersRef.current).forEach(clearTimeout); + Object.values(itemCodeTimersRef.current).forEach(clearTimeout); + }; + }, []); + + useEffect(() => { + rows.forEach((row) => { + const needsPreview = + row.previewBaseQty == null && row.previewStockQty == null; + if ( + needsPreview && + row.itemId && + row.uomId && + row.salesUomId != null && + row.stockUomId != null && + row.baseUomId != null + ) { + schedulePreview(row.key, row); + } + }); + }, [rows, schedulePreview]); + + if (rows.length === 0) { + return ( + {t("No materials parsed")} + ); + } + + return ( + + + + {t("Item Code")} + {t("Item Name")} + {t("Recipe Qty")} + {t("Recipe UOM")} + {t("Base Qty")} + {t("Stock Qty")} + {t("Join Step")} + + + + {rows.map((row) => { + const rowErrors = formatErrorsByRowKey?.[row.key]; + const itemCodeError = firstFieldError(rowErrors?.itemCode ?? rowErrors?.general); + const qtyError = firstFieldError(rowErrors?.qty); + const uomError = firstFieldError(rowErrors?.uom); + const joinStepError = firstFieldError(rowErrors?.joinStep); + const showUomSelect = + row.availableRecipeUoms.length > 1 || Boolean(uomError); + return ( + + + { + const nextCode = e.target.value; + updateRow(row.key, { itemCode: nextCode }); + resolveItemCode( + row.key, + nextCode, + row.uomId || undefined, + row.uomCode, + ); + }} + error={Boolean(itemCodeError) || hasFieldError(rowErrors?.itemCode)} + helperText={itemCodeError} + sx={{ width: 120 }} + /> + + {row.itemName || "-"} + + + updateRow(row.key, { qty: Number(e.target.value) }) + } + error={Boolean(qtyError)} + helperText={qtyError} + inputProps={{ min: 0, step: "any" }} + sx={{ width: 100 }} + /> + + + {!showUomSelect ? ( + + {row.availableRecipeUoms.find((u) => u.uomId === row.uomId) + ?.label ?? + row.availableRecipeUoms[0]?.label ?? + (row.uomCode || "-")} + {uomError && ( + + {uomError} + + )} + + ) : ( + + + {uomError && ( + + {uomError} + + )} + + )} + + + {fmtQtyUom(row.previewBaseQty, row.baseUom)} + + + {fmtQtyUom(row.previewStockQty, row.stockUom)} + + + {processStepOptions.length === 0 ? ( + row.joinStepSeqNo ?? "-" + ) : ( + + + {joinStepError && ( + + {joinStepError} + + )} + + )} + + + ); + })} + +
+ ); +} diff --git a/src/components/ImportBom/BomImportPreviewDialog.tsx b/src/components/ImportBom/BomImportPreviewDialog.tsx new file mode 100644 index 0000000..de1a2e5 --- /dev/null +++ b/src/components/ImportBom/BomImportPreviewDialog.tsx @@ -0,0 +1,1169 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + Grid, + InputAdornment, + InputLabel, + ListItemText, + MenuItem, + Select, + Stack, + Tab, + Tabs, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { + BomImportItemOverrides, + BomImportPreviewLine, + BomProductType, +} from "@/app/api/bom"; +import { + downloadCorrectedImportBomExcelClient, + fetchAllEquipmentsMasterClient, + fetchAllProcessesMasterClient, + fetchBagItemsComboClient, + fetchItemExistsByCodeClient, + revalidateImportBomItemClient, + type EquipmentMasterRow, + type ProcessMasterRow, +} from "@/app/api/bom/client"; +import { + firstFieldError, + hasFieldError, + mapFormatProblemsToFields, +} from "./bomImportFormatFieldErrors"; +import { BomAttributeScaleSlider } from "./BomAttributeScaleDisplay"; +import { + COLOR_DEPTH_SCALE, + COMPLEXITY_SCALE, + DENSITY_SCALE, + FLOAT_SINK_SCALE, + TIME_SEQUENCE_SCALE, + allowedStepsForField, + localScaleFieldError, + scaleForImportSave, + scaleStateFromImport, + validateImportBasicScalesStrict, + type BomScaleField, +} from "./bomAttributeScales"; +import BomImportMaterialEditTable, { + type ImportProcessStepOption, +} from "./BomImportMaterialEditTable"; +import { + equipmentNamesForDescription, + formatProcessLabel, + isPackagingProcess, + type BagItemCombo, +} from "./bomProcessUtils"; +import { + type ImportMaterialEditRow, + materialRowsFromOverrideOrPreview, + materialRowsToOverride, + processRowsFromOverrideOrPreview, + processRowsToOverride, + validateImportBasicFields, + validateImportMaterialRows, + validateImportMaterialRowsAsync, + validateImportProcessRows, + validateImportProcessRowsAsync, +} from "./bomImportPreviewUtils"; +import type { ProcessEditRow } from "./BomProcessEditPanel"; +import { + type BomImportKindMode, + bomKindModeFromImport, + bomKindModeLabel, + importFlagsFromBomKindMode, +} from "./bomImportKindMode"; + +const PRODUCT_TYPE_OPTIONS: BomProductType[] = ["drink", "powder_mixture", "other"]; +const BOM_KIND_MODE_OPTIONS: BomImportKindMode[] = ["FG", "WIP", "both"]; + +const editFieldSx = { + m: 0, + width: "100%", + "& .MuiOutlinedInput-root": { minHeight: 40 }, +} as const; + +const packagingSelectSx = { + m: 0, + width: "100%", + minWidth: 180, + "& .MuiOutlinedInput-root": { minHeight: 40, height: "auto", alignItems: "flex-start" }, + "& .MuiSelect-select": { + whiteSpace: "normal", + wordBreak: "break-word", + maxHeight: 100, + overflowY: "auto", + }, +} as const; + +export type BomImportPreviewEditResult = { + isAlsoWip: boolean; + productType: BomProductType; + overrides: BomImportItemOverrides; +}; + +type Props = { + open: boolean; + batchId?: string; + fileName?: string; + preview?: BomImportPreviewLine; + formatProblems?: string[]; + initialIsAlsoWip: boolean; + initialProductType: BomProductType; + initialOverrides?: BomImportItemOverrides; + closeOnSave?: boolean; + onClose: () => void; + onSave: (result: BomImportPreviewEditResult) => void | Promise; + onLiveFormatProblemsChange?: (problems: string[]) => void; +}; + +function productTypeLabel(t: (key: string) => string, productType: BomProductType): string { + if (productType === "drink") return t("Drink"); + if (productType === "powder_mixture") return t("Powder_Mixture"); + return t("Other"); +} + +function numOrEmpty(v: number | null | undefined): string { + return v == null ? "" : String(v); +} + +function parseNumInput(raw: string): number | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 14 | v1.0.0 | 2026-07-16 */ +export default function BomImportPreviewDialog({ + open, + batchId, + fileName, + preview, + formatProblems, + initialIsAlsoWip, + initialProductType, + initialOverrides, + closeOnSave = true, + onClose, + onSave, + onLiveFormatProblemsChange, +}: Props) { + const { t } = useTranslation("importBom"); + const [tab, setTab] = useState(0); + const [saveError, setSaveError] = useState(null); + const [saving, setSaving] = useState(false); + const [downloading, setDownloading] = useState(false); + const saveInFlightRef = useRef(false); + const downloadInFlightRef = useRef(false); + const formatRevalidateInFlightRef = useRef(false); + const formatRevalidateSeqRef = useRef(0); + const formatRevalidateTimerRef = useRef | null>(null); + + const [liveFormatProblems, setLiveFormatProblems] = useState([]); + + const [masterLoading, setMasterLoading] = useState(false); + const [masterError, setMasterError] = useState(null); + const [equipmentList, setEquipmentList] = useState([]); + const [processList, setProcessList] = useState([]); + const [bagItems, setBagItems] = useState([]); + const [byProductErrors, setByProductErrors] = useState>({}); + const byProductCheckInFlightRef = useRef>({}); + + const [bomKindMode, setBomKindMode] = useState("FG"); + const [productType, setProductType] = useState(initialProductType); + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [outputQty, setOutputQty] = useState(""); + const [outputQtyUom, setOutputQtyUom] = useState(""); + const [putawayLocationCode, setPutawayLocationCode] = useState(""); + const [isDark, setIsDark] = useState(0); + const [isFloat, setIsFloat] = useState(0); + const [isDense, setIsDense] = useState(0); + const [scrapRate, setScrapRate] = useState(null); + const [timeSequence, setTimeSequence] = useState(0); + const [complexity, setComplexity] = useState(0); + const [allergicSubstances, setAllergicSubstances] = useState(0); + + const [materialRows, setMaterialRows] = useState([]); + const [processRows, setProcessRows] = useState([]); + + const equipmentDescriptionOptions = useMemo(() => { + const s = new Set(); + equipmentList.forEach((e) => { + if (e.description) s.add(e.description); + }); + return Array.from(s).sort(); + }, [equipmentList]); + + const processStepOptions = useMemo( + () => + processRows + .filter((r) => r.seqNo != null) + .map((r) => ({ + seqNo: r.seqNo!, + label: `${r.seqNo}. ${r.processName || r.processCode || "-"}`, + })) + .sort((a, b) => a.seqNo - b.seqNo), + [processRows], + ); + + const processSeqOptions = useMemo( + () => processStepOptions.map((p) => p.seqNo), + [processStepOptions], + ); + + useEffect(() => { + if (!open) return; + setLiveFormatProblems(formatProblems ?? []); + }, [open, formatProblems]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + setMasterLoading(true); + setMasterError(null); + setSaveError(null); + setTab(0); + setLiveFormatProblems(formatProblems ?? []); + try { + const [equipments, processes, bags] = await Promise.all([ + fetchAllEquipmentsMasterClient(), + fetchAllProcessesMasterClient(), + fetchBagItemsComboClient(), + ]); + if (cancelled) return; + setEquipmentList(equipments); + setProcessList(processes); + setBagItems(bags); + + const o = initialOverrides; + const p = preview; + const resolvedBomKind = o?.bomKind ?? p?.bomKind ?? "FG"; + setBomKindMode( + bomKindModeFromImport(initialIsAlsoWip, resolvedBomKind), + ); + setProductType(initialProductType); + setCode(o?.code ?? p?.code ?? ""); + setName(o?.name ?? p?.name ?? ""); + setOutputQty(numOrEmpty(o?.outputQty ?? p?.outputQty)); + setOutputQtyUom(o?.outputQtyUom ?? p?.outputQtyUom ?? ""); + setPutawayLocationCode(o?.putawayLocationCode ?? p?.putawayLocationCode ?? ""); + setIsDark(scaleStateFromImport(o?.isDark ?? p?.isDark, COLOR_DEPTH_SCALE)); + setIsFloat(scaleStateFromImport(o?.isFloat ?? p?.isFloat, FLOAT_SINK_SCALE)); + setIsDense(scaleStateFromImport(o?.isDense ?? p?.isDense, DENSITY_SCALE)); + setScrapRate(o?.scrapRate ?? p?.scrapRate ?? null); + setTimeSequence(scaleStateFromImport(o?.timeSequence ?? p?.timeSequence, TIME_SEQUENCE_SCALE)); + setComplexity(scaleStateFromImport(o?.complexity ?? p?.complexity, COMPLEXITY_SCALE)); + setAllergicSubstances(o?.allergicSubstances ?? p?.allergicSubstances ?? 0); + setByProductErrors({}); + + setMaterialRows( + materialRowsFromOverrideOrPreview(o?.materials, p?.materials), + ); + setProcessRows( + processRowsFromOverrideOrPreview( + o?.processes, + p?.processes, + processes, + equipments, + bags, + ), + ); + } catch (e: unknown) { + if (!cancelled) { + setMasterError( + e instanceof Error ? e.message : t("Load process master failed"), + ); + } + } finally { + if (!cancelled) setMasterLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, preview, initialIsAlsoWip, initialProductType, initialOverrides, t]); + + const updateProcessRow = (key: string, patch: Partial) => { + setProcessRows((prev) => + prev.map((r) => { + if (r.key !== key) return r; + const next = { ...r, ...patch }; + if (patch.processCode != null) { + const proc = processList.find((p) => p.code === patch.processCode); + next.processName = proc?.name ?? ""; + if (patch.processCode.trim()) { + next.importedProcessName = undefined; + } + if (!isPackagingProcess(patch.processCode)) { + next.selectedBagCodes = []; + } + } + return next; + }), + ); + }; + + const checkByProductCode = useCallback( + async (rowKey: string, itemCode: string): Promise => { + const trimmed = itemCode.trim(); + if (!trimmed) { + setByProductErrors((prev) => { + const next = { ...prev }; + delete next[rowKey]; + return next; + }); + return null; + } + if (byProductCheckInFlightRef.current[rowKey]) return null; + byProductCheckInFlightRef.current[rowKey] = true; + try { + const result = await fetchItemExistsByCodeClient(trimmed); + if (!result.exists) { + const msg = t("Byproduct item not in master", { code: trimmed }); + setByProductErrors((prev) => ({ ...prev, [rowKey]: msg })); + return msg; + } + setByProductErrors((prev) => { + const next = { ...prev }; + delete next[rowKey]; + return next; + }); + return null; + } catch { + const msg = t("Byproduct item not in master", { code: trimmed }); + setByProductErrors((prev) => ({ ...prev, [rowKey]: msg })); + return msg; + } finally { + byProductCheckInFlightRef.current[rowKey] = false; + } + }, + [t], + ); + + const buildOverridesPayload = useCallback((): BomImportItemOverrides => { + const { bomKind } = importFlagsFromBomKindMode(bomKindMode); + return { + code: code.trim() || null, + name: name.trim() || null, + bomKind, + outputQty: parseNumInput(outputQty), + outputQtyUom: outputQtyUom.trim() || null, + putawayLocationCode: putawayLocationCode.trim() || null, + isDark: scaleForImportSave(isDark, COLOR_DEPTH_SCALE), + isFloat: scaleForImportSave(isFloat, FLOAT_SINK_SCALE), + isDense: scaleForImportSave(isDense, DENSITY_SCALE), + scrapRate, + timeSequence: scaleForImportSave(timeSequence, TIME_SEQUENCE_SCALE), + complexity: scaleForImportSave(complexity, COMPLEXITY_SCALE), + allergicSubstances, + materials: materialRowsToOverride(materialRows), + processes: processRowsToOverride(processRows, equipmentList, bagItems), + }; + }, [ + allergicSubstances, + bagItems, + bomKindMode, + code, + complexity, + equipmentList, + isDark, + isDense, + isFloat, + materialRows, + name, + outputQty, + outputQtyUom, + putawayLocationCode, + processRows, + scrapRate, + timeSequence, + ]); + + const revalidateDepsKey = useMemo( + () => JSON.stringify(buildOverridesPayload()), + [buildOverridesPayload], + ); + + useEffect(() => { + if (!open || !batchId || !fileName || masterLoading) return; + + if (formatRevalidateTimerRef.current) { + clearTimeout(formatRevalidateTimerRef.current); + } + + formatRevalidateTimerRef.current = setTimeout(() => { + const seq = ++formatRevalidateSeqRef.current; + void (async () => { + formatRevalidateInFlightRef.current = true; + try { + const res = await revalidateImportBomItemClient( + batchId, + fileName, + buildOverridesPayload(), + ); + if (seq !== formatRevalidateSeqRef.current) return; + setLiveFormatProblems(res.problems); + onLiveFormatProblemsChange?.(res.problems); + } catch { + // keep previous format problems + } finally { + formatRevalidateInFlightRef.current = false; + } + })(); + }, 500); + + return () => { + if (formatRevalidateTimerRef.current) { + clearTimeout(formatRevalidateTimerRef.current); + } + }; + }, [open, batchId, fileName, masterLoading, revalidateDepsKey, buildOverridesPayload, onLiveFormatProblemsChange]); + + const mappedFormatProblems = useMemo( + () => mapFormatProblemsToFields(liveFormatProblems, materialRows, processRows), + [liveFormatProblems, materialRows, processRows], + ); + + const basicFieldError = (key: keyof typeof mappedFormatProblems.basic) => ({ + error: hasFieldError(mappedFormatProblems.basic[key]), + helperText: firstFieldError(mappedFormatProblems.basic[key]), + }); + + const scaleFieldError = (field: BomScaleField, value: number) => { + const backend = firstFieldError(mappedFormatProblems.basic[field]); + const local = localScaleFieldError(field, value); + const helperText = backend ?? local; + return { + error: Boolean(helperText), + helperText, + }; + }; + + const importScaleValues = useMemo( + () => ({ + isDark, + isFloat, + isDense, + timeSequence, + complexity, + allergicSubstances, + }), + [isDark, isFloat, isDense, timeSequence, complexity, allergicSubstances], + ); + + const clientScaleError = useMemo( + () => validateImportBasicScalesStrict(importScaleValues), + [importScaleValues], + ); + + const validateBeforePersist = async (): Promise => { + const basicErr = validateImportBasicFields(t, { code, name, outputQty }); + if (basicErr) { + setSaveError(basicErr); + setTab(0); + return false; + } + const scaleErr = validateImportBasicScalesStrict(importScaleValues); + if (scaleErr) { + setSaveError(scaleErr); + setTab(0); + return false; + } + const matErr = validateImportMaterialRows(t, materialRows, processSeqOptions); + if (matErr) { + setSaveError(matErr); + setTab(1); + return false; + } + const procErr = validateImportProcessRows( + t, + processRows, + processList, + equipmentList, + bagItems, + byProductErrors, + ); + if (procErr) { + setSaveError(procErr); + setTab(2); + return false; + } + const matAsyncErr = await validateImportMaterialRowsAsync(t, materialRows); + if (matAsyncErr) { + setSaveError(matAsyncErr); + setTab(1); + return false; + } + const procAsyncErr = await validateImportProcessRowsAsync( + processRows, + checkByProductCode, + ); + if (procAsyncErr) { + setSaveError(procAsyncErr); + setTab(2); + return false; + } + return true; + }; + + const handleSave = async () => { + if (saveInFlightRef.current) return; + saveInFlightRef.current = true; + setSaving(true); + setSaveError(null); + try { + const ok = await validateBeforePersist(); + if (!ok) return; + + const { isAlsoWip } = importFlagsFromBomKindMode(bomKindMode); + onSave({ + isAlsoWip, + productType, + overrides: buildOverridesPayload(), + }); + if (closeOnSave) onClose(); + } finally { + setSaving(false); + saveInFlightRef.current = false; + } + }; + + const handleDownloadCorrected = async () => { + if (!batchId || !fileName || downloadInFlightRef.current) return; + downloadInFlightRef.current = true; + setDownloading(true); + setSaveError(null); + try { + const ok = await validateBeforePersist(); + if (!ok) return; + const { blob, fileName: downloadName } = await downloadCorrectedImportBomExcelClient( + batchId, + fileName, + buildOverridesPayload(), + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = downloadName; + a.click(); + URL.revokeObjectURL(url); + } catch (e: unknown) { + setSaveError(e instanceof Error ? e.message : t("Download corrected excel failed")); + } finally { + setDownloading(false); + downloadInFlightRef.current = false; + } + }; + + const renderProcessDescriptionCell = (row: ProcessEditRow) => { + if (isPackagingProcess(row.processCode)) { + return ( + + + + ); + } + return ( + updateProcessRow(row.key, { description: e.target.value })} + sx={editFieldSx} + /> + ); + }; + + const renderEquipmentCell = (row: ProcessEditRow) => { + const hasDescription = Boolean(row.equipmentDescription.trim()); + const nameOptions = equipmentNamesForDescription( + equipmentList, + row.equipmentDescription, + ); + return ( + + + {t("Equipment Description")} + + + + {t("Equipment Name")} + + + + ); + }; + + const dialogTitleSuffix = useMemo(() => { + const parts = [code.trim(), name.trim()].filter(Boolean); + return parts.length > 0 ? ` ${parts.join(" — ")}` : ""; + }, [code, name]); + + const hasBasicFormatIssues = useMemo( + () => Object.values(mappedFormatProblems.basic).some((msgs) => Boolean(msgs?.length)), + [mappedFormatProblems.basic], + ); + + const hasBlockingProblems = useMemo(() => { + if (liveFormatProblems.length > 0) return true; + if (clientScaleError) return true; + return Object.values(byProductErrors).some((msg) => Boolean(msg?.trim())); + }, [liveFormatProblems, clientScaleError, byProductErrors]); + + const tabErrorSx = { color: "error.main" } as const; + + return ( + + + {t("Review import data")} + {dialogTitleSuffix} + + + {liveFormatProblems.length > 0 && ( + + + {t("Format check issues")} + + + {liveFormatProblems.map((p, i) => ( + + {p} + + ))} + + + )} + {(masterError || saveError) && ( + + {saveError ?? masterError} + + )} + {masterLoading ? ( + {t("Loading...")} + ) : ( + <> + setTab(v)} sx={{ mb: 2 }}> + + + + + + {tab === 0 && ( + + + + setBomKindMode(e.target.value as BomImportKindMode) + } + {...basicFieldError("bomKindMode")} + sx={{ minWidth: 140 }} + > + {BOM_KIND_MODE_OPTIONS.map((opt) => ( + + {bomKindModeLabel(t, opt)} + + ))} + + + setProductType(e.target.value as BomProductType) + } + sx={{ minWidth: 140 }} + > + {PRODUCT_TYPE_OPTIONS.map((opt) => ( + + {productTypeLabel(t, opt)} + + ))} + + + + + + setCode(e.target.value)} + {...basicFieldError("code")} + /> + + + setName(e.target.value)} + {...basicFieldError("name")} + /> + + + setOutputQty(e.target.value)} + {...basicFieldError("outputQty")} + /> + + + setOutputQtyUom(e.target.value)} + {...basicFieldError("outputQtyUom")} + /> + + + setPutawayLocationCode(e.target.value)} + /> + + + + + + {t("Attribute scales")} + + + + + + {scaleFieldError("isDark", isDark).helperText && ( + + {scaleFieldError("isDark", isDark).helperText} + + )} + + + + + + {scaleFieldError("isFloat", isFloat).helperText && ( + + {scaleFieldError("isFloat", isFloat).helperText} + + )} + + + + + + {scaleFieldError("isDense", isDense).helperText && ( + + {scaleFieldError("isDense", isDense).helperText} + + )} + + + + + + {scaleFieldError("timeSequence", timeSequence).helperText && ( + + {scaleFieldError("timeSequence", timeSequence).helperText} + + )} + + + + + + {scaleFieldError("complexity", complexity).helperText && ( + + {scaleFieldError("complexity", complexity).helperText} + + )} + + + + + setScrapRate(parseNumInput(e.target.value)) + } + InputProps={{ + endAdornment: ( + % + ), + }} + /> + + + + {t("Allergic Substances")} + + + + + + + )} + + {tab === 1 && ( + + + + )} + + {tab === 2 && ( + + {processRows.length === 0 ? ( + + {t("No processes parsed")} + + ) : ( + + + + {t("Sequence")} + {t("Process Name")} + + {t("Process Description")} + + {t("Byproduct")} + {t("Equipment")} + + {t("Duration (Minutes)")} + + + {t("Prep Time (Minutes)")} + + + {t("Post Prod Time (Minutes)")} + + + + + {processRows.map((row) => { + const processErrors = + mappedFormatProblems.processByRowKey[row.key] + ?.processCode; + const processHelper = + processErrors?.[0] ?? + (row.importedProcessName && !row.processCode.trim() + ? t("Import process not in master", { + name: row.importedProcessName, + }) + : undefined); + const processFieldError = Boolean(processHelper); + return ( + + {row.seqNo ?? "-"} + + + + {processHelper && ( + + {processHelper} + + )} + + + {renderProcessDescriptionCell(row)} + + + updateProcessRow(row.key, { + byProduct: e.target.value, + }) + } + onBlur={() => + void checkByProductCode( + row.key, + row.byProduct, + ) + } + error={Boolean(byProductErrors[row.key])} + helperText={byProductErrors[row.key]} + sx={editFieldSx} + /> + + {renderEquipmentCell(row)} + + + updateProcessRow(row.key, { + durationInMinute: Number(e.target.value), + }) + } + sx={{ ...editFieldSx, width: 72 }} + /> + + + + updateProcessRow(row.key, { + prepTimeInMinute: Number(e.target.value), + }) + } + sx={{ ...editFieldSx, width: 72 }} + /> + + + + updateProcessRow(row.key, { + postProdTimeInMinute: Number( + e.target.value, + ), + }) + } + sx={{ ...editFieldSx, width: 72 }} + /> + + + ); + })} + +
+ )} +
+ )} + + )} +
+ + + {batchId && fileName && ( + + )} + + +
+ ); +} diff --git a/src/components/ImportBom/BomMaterialEditPanel.tsx b/src/components/ImportBom/BomMaterialEditPanel.tsx new file mode 100644 index 0000000..53a90c8 --- /dev/null +++ b/src/components/ImportBom/BomMaterialEditPanel.tsx @@ -0,0 +1,456 @@ +"use client"; + +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { + Button, + Checkbox, + FormControl, + ListItemText, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import CancelIcon from "@mui/icons-material/Cancel"; +import type { BomDetailResponse, BomMaterialDto, BomProcessDto } from "@/app/api/bom"; +import { recalculateBomMaterialQtyClient } from "@/app/api/masterDataIssues/client"; +import { useTranslation } from "react-i18next"; +import { + buildOldMaterialMap, + fmtQtyUom, + materialDiffCell, +} from "./bomDiff"; + +export type MaterialEditRow = { + key: string; + sourceBomMaterialId?: number; + itemCode: string; + itemName?: string; + itemId?: number; + qty: number; + uomId: number; + salesUomId?: number; + stockUomId?: number; + baseUomId?: number; + previewBaseQty?: number; + previewStockQty?: number; + baseUom?: string; + stockUom?: string; + processSteps: string[]; + processStepIds: number[]; + availableRecipeUoms: { uomId: number; label: string }[]; +}; + +export type BomMaterialEditPanelHandle = { + isEditing: () => boolean; + hasMaterialChanges: () => boolean; + getMaterialSaveLines: () => Array<{ + sourceBomMaterialId?: number; + itemCode: string; + qty: number; + uomId: number; + bomProcessIds: number[]; + }>; + cancelEdit: () => void; +}; + +type Props = { + detail: BomDetailResponse; + compareOldDetail?: BomDetailResponse | null; + comparing?: boolean; + editDisabled?: boolean; + onDirtyChange?: (dirty: boolean) => void; +}; + +function fmtQty(v?: number | null): string { + if (v == null || Number.isNaN(v)) return "-"; + return String(v); +} + +function fmtProcessSteps(steps?: string[] | null): string { + if (!steps?.length) return "-"; + return steps.join("、"); +} + +function sameIdSet(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort((x, y) => x - y); + const sortedB = [...b].sort((x, y) => x - y); + return sortedA.every((id, i) => id === sortedB[i]); +} + +function buildProcessOptions(processes: BomProcessDto[]) { + return processes + .filter((p) => p.id != null) + .map((p) => ({ + id: p.id!, + label: + p.seqNo != null + ? `${p.seqNo}. ${p.processName ?? "-"}` + : (p.processName ?? "-"), + })); +} + +function labelsForProcessIds( + processOptions: Array<{ id: number; label: string }>, + ids: number[], +): string[] { + const byId = new Map(processOptions.map((p) => [p.id, p.label])); + return ids.map((id) => byId.get(id) ?? String(id)); +} + +function toEditRow(m: BomMaterialDto, index: number): MaterialEditRow | null { + if (!m.itemCode || m.recipeUomId == null) return null; + return { + key: `${m.id ?? index}-${m.itemCode}`, + sourceBomMaterialId: m.id, + itemCode: m.itemCode, + itemName: m.itemName, + itemId: m.itemId, + qty: Number(m.recipeQty ?? m.baseQty ?? 0), + uomId: m.recipeUomId, + salesUomId: m.salesUomId, + stockUomId: m.stockUomId, + baseUomId: m.baseUomId, + previewBaseQty: m.baseQty, + previewStockQty: m.stockQty, + baseUom: m.baseUom, + stockUom: m.stockUom, + processSteps: m.processSteps ?? [], + processStepIds: m.processStepIds ?? [], + availableRecipeUoms: m.availableRecipeUoms ?? [], + }; +} + +function rowsMatchDetail(rows: MaterialEditRow[], materials: BomMaterialDto[]): boolean { + if (rows.length !== materials.length) return false; + const byCode = new Map(materials.map((m) => [m.itemCode ?? "", m])); + for (const row of rows) { + const m = byCode.get(row.itemCode); + if (!m) return false; + const sameQty = Number(m.recipeQty ?? m.baseQty ?? 0) === row.qty; + const sameUom = m.recipeUomId === row.uomId; + const sameSteps = sameIdSet(m.processStepIds ?? [], row.processStepIds); + if (!sameQty || !sameUom || !sameSteps) return false; + } + return true; +} + +export const BomMaterialEditPanel = forwardRef( + function BomMaterialEditPanel( + { + detail, + compareOldDetail = null, + comparing = false, + editDisabled = false, + onDirtyChange, + }, + ref, + ) { + const { t } = useTranslation("importBom"); + const oldMaterialMap = buildOldMaterialMap(compareOldDetail); + const processOptions = buildProcessOptions(detail.processes ?? []); + const [isEditing, setIsEditing] = useState(false); + const [rows, setRows] = useState([]); + const previewTimersRef = useRef>>({}); + + useEffect(() => { + if (!isEditing) return; + onDirtyChange?.(!rowsMatchDetail(rows, detail.materials)); + }, [rows, isEditing, detail.materials, onDirtyChange]); + + const startEdit = useCallback(() => { + if (editDisabled) return; + const next = detail.materials + .map((m, i) => toEditRow(m, i)) + .filter((r): r is MaterialEditRow => r != null); + setRows(next); + setIsEditing(true); + }, [detail, editDisabled]); + + const cancelEdit = useCallback(() => { + setIsEditing(false); + onDirtyChange?.(false); + Object.values(previewTimersRef.current).forEach(clearTimeout); + previewTimersRef.current = {}; + }, [onDirtyChange]); + + useImperativeHandle( + ref, + () => ({ + isEditing: () => isEditing, + hasMaterialChanges: () => + isEditing && !rowsMatchDetail(rows, detail.materials), + getMaterialSaveLines: () => + rows.map((r) => ({ + sourceBomMaterialId: r.sourceBomMaterialId, + itemCode: r.itemCode, + qty: r.qty, + uomId: r.uomId, + bomProcessIds: r.processStepIds, + })), + cancelEdit, + }), + [cancelEdit, detail.materials, isEditing, rows], + ); + + const schedulePreview = useCallback((key: string, row: MaterialEditRow) => { + if (previewTimersRef.current[key]) { + clearTimeout(previewTimersRef.current[key]); + } + previewTimersRef.current[key] = setTimeout(async () => { + if ( + !row.itemId || + row.salesUomId == null || + row.stockUomId == null || + row.baseUomId == null + ) { + return; + } + try { + const result = await recalculateBomMaterialQtyClient({ + itemId: row.itemId, + anchor: "RECIPE_QTY", + qty: row.qty, + salesUomId: row.salesUomId, + stockUomId: row.stockUomId, + baseUomId: row.baseUomId, + recipeUomId: row.uomId, + }); + setRows((prev) => + prev.map((r) => + r.key === key + ? { + ...r, + previewBaseQty: result.baseQty ?? undefined, + previewStockQty: result.stockQty ?? undefined, + baseUom: result.baseUom ?? r.baseUom, + stockUom: result.stockUom ?? r.stockUom, + } + : r, + ), + ); + } catch { + // keep previous preview + } + }, 300); + }, []); + + const updateRow = useCallback( + (key: string, patch: Partial) => { + setRows((prev) => { + const next = prev.map((r) => (r.key === key ? { ...r, ...patch } : r)); + const updated = next.find((r) => r.key === key); + if (updated) schedulePreview(key, updated); + return next; + }); + }, + [schedulePreview], + ); + + useEffect(() => { + return () => { + Object.values(previewTimersRef.current).forEach(clearTimeout); + }; + }, []); + + useEffect(() => { + cancelEdit(); + }, [detail.id, cancelEdit]); + + useEffect(() => { + if (editDisabled && isEditing) { + cancelEdit(); + } + }, [editDisabled, isEditing, cancelEdit]); + + return ( + + + {t("Bom Material")} + {!isEditing ? ( + + ) : ( + + )} + + + + + + {t("Item Code")} + {t("Item Name")} + {t("Recipe Qty")} + {t("Recipe UOM")} + {t("Base Qty")} + {t("Stock Qty")} + {t("Join Step")} + + + + {isEditing + ? rows.map((row) => ( + + {row.itemCode} + {row.itemName} + + + updateRow(row.key, { qty: Number(e.target.value) }) + } + inputProps={{ min: 0, step: "any" }} + sx={{ width: 100 }} + /> + + + {row.availableRecipeUoms.length <= 1 ? ( + + {row.availableRecipeUoms.find((u) => u.uomId === row.uomId) + ?.label ?? + row.availableRecipeUoms[0]?.label ?? + "-"} + + ) : ( + + + + )} + + + {fmtQtyUom(row.previewBaseQty, row.baseUom)} + + + {fmtQtyUom(row.previewStockQty, row.stockUom)} + + + {processOptions.length === 0 ? ( + fmtProcessSteps(row.processSteps) + ) : ( + + + + )} + + + )) + : detail.materials.map((m, i) => { + const oldM = oldMaterialMap.get(m.itemCode ?? ""); + return ( + + {m.itemCode} + {m.itemName} + + {materialDiffCell( + oldM, + m, + (row) => fmtQty(row.recipeQty ?? row.baseQty), + comparing, + )} + + + {materialDiffCell( + oldM, + m, + (row) => row.recipeUom ?? row.baseUom ?? "-", + comparing, + )} + + + {materialDiffCell( + oldM, + m, + (row) => fmtQtyUom(row.baseQty, row.baseUom), + comparing, + )} + + + {materialDiffCell( + oldM, + m, + (row) => fmtQtyUom(row.stockQty, row.stockUom), + comparing, + )} + + + {materialDiffCell( + oldM, + m, + (row) => fmtProcessSteps(row.processSteps), + comparing, + )} + + + ); + })} + +
+ {isEditing && ( + + {t("Material edit creates new version hint")} + + )} +
+ ); + }, +); diff --git a/src/components/ImportBom/BomProcessEditPanel.tsx b/src/components/ImportBom/BomProcessEditPanel.tsx new file mode 100644 index 0000000..125e44a --- /dev/null +++ b/src/components/ImportBom/BomProcessEditPanel.tsx @@ -0,0 +1,777 @@ +"use client"; + +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { + Box, + Button, + Checkbox, + FormControl, + InputLabel, + ListItemText, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import CancelIcon from "@mui/icons-material/Cancel"; +import type { BomDetailResponse, BomProcessDto } from "@/app/api/bom"; +import { + fetchAllEquipmentsMasterClient, + fetchAllProcessesMasterClient, + fetchBagItemsComboClient, + fetchItemExistsByCodeClient, + type EquipmentMasterRow, + type ProcessMasterRow, +} from "@/app/api/bom/client"; +import { useTranslation } from "react-i18next"; +import { + buildProcessSaveLine, + processVersionLinesFromDetail, +} from "./bomVersionSaveUtils"; +import { buildOldProcessMap, processDiffCell } from "./bomDiff"; +import { + equipmentNamesForDescription, + formatProcessDescriptionDisplay, + formatProcessLabel, + isPackagingProcess, + parsePackagingBagCodes, + resolveEquipmentCode, + splitEquipmentFromDetail, + type BagItemCombo, +} from "./bomProcessUtils"; + +export type ProcessEditRow = { + key: string; + seqNo?: number; + processCode: string; + processName: string; + /** Excel/import label when master lookup failed */ + importedProcessName?: string; + description: string; + byProduct: string; + byProductUom: string; + equipmentDescription: string; + equipmentName: string; + selectedBagCodes: string[]; + durationInMinute: number; + prepTimeInMinute: number; + postProdTimeInMinute: number; +}; + +export type BomProcessEditPanelHandle = { + isEditing: () => boolean; + hasProcessChanges: () => boolean; + validate: () => string | null; + validateAsync: () => Promise; + getProcessSaveLines: () => ReturnType[]; + cancelEdit: () => void; +}; + +type Props = { + detail: BomDetailResponse; + compareOldDetail?: BomDetailResponse | null; + comparing?: boolean; + onDirtyChange?: (dirty: boolean) => void; +}; + +/** Keep table row inputs aligned (small outlined controls ≈ 40px). */ +const editCellSx = { verticalAlign: "top", pt: 1.25, pb: 1.25 } as const; + +const editFieldSx = { + m: 0, + width: "100%", + "& .MuiOutlinedInput-root": { + minHeight: 40, + height: 40, + boxSizing: "border-box", + }, + "& .MuiOutlinedInput-input": { + py: "8.5px", + boxSizing: "border-box", + }, + "& .MuiSelect-select": { + py: "8.5px", + minHeight: "unset", + height: "100%", + boxSizing: "border-box", + display: "flex", + alignItems: "center", + }, +} as const; + +/** Packaging bag multi-select: grow vertically, scroll inside outline. */ +const packagingSelectSx = { + m: 0, + width: "100%", + minWidth: 220, + maxWidth: 360, + "& .MuiOutlinedInput-root": { + minHeight: 40, + height: "auto", + alignItems: "flex-start", + overflow: "hidden", + boxSizing: "border-box", + }, + "& .MuiSelect-select": { + display: "block !important", + width: "100% !important", + maxWidth: "100%", + whiteSpace: "normal", + wordBreak: "break-word", + overflowWrap: "anywhere", + height: "auto !important", + minHeight: 23, + maxHeight: 120, + overflowY: "auto", + overflowX: "hidden", + py: "8.5px", + boxSizing: "border-box", + }, +} as const; + +function processNameForCode(list: ProcessMasterRow[], code: string): string { + return list.find((p) => p.code === code)?.name ?? ""; +} + +function toEditRow( + p: BomProcessDto, + index: number, + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], +): ProcessEditRow { + const processCode = (p.processCode ?? "").trim(); + const { equipmentDescription, equipmentName } = splitEquipmentFromDetail( + equipmentList, + p.equipmentCode, + ); + return { + key: `${p.id ?? index}-${p.seqNo ?? index}`, + seqNo: p.seqNo, + processCode, + processName: p.processName ?? processNameForCode([], processCode), + description: isPackagingProcess(processCode) ? "" : (p.processDescription ?? ""), + byProduct: p.byProduct ?? "", + byProductUom: p.byProductUom ?? "", + equipmentDescription, + equipmentName, + selectedBagCodes: isPackagingProcess(processCode) + ? parsePackagingBagCodes(p.processDescription) + : [], + durationInMinute: p.durationInMinute ?? 0, + prepTimeInMinute: p.prepTimeInMinute ?? 0, + postProdTimeInMinute: p.postProdTimeInMinute ?? 0, + }; +} + +function rowsMatchDetail( + rows: ProcessEditRow[], + detail: BomDetailResponse, + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], +): boolean { + const baseline = processVersionLinesFromDetail(detail, equipmentList, bagItems); + const current = rows.map((r) => + buildProcessSaveLine(r, equipmentList, bagItems), + ); + if (baseline.length !== current.length) return false; + return baseline.every((b, i) => { + const c = current[i]; + return ( + b.seqNo === c.seqNo && + b.processCode === c.processCode && + (b.description ?? "") === (c.description ?? "") && + (b.byProduct ?? "") === (c.byProduct ?? "") && + (b.byProductUom ?? "") === (c.byProductUom ?? "") && + (b.equipmentCode ?? "") === (c.equipmentCode ?? "") && + b.durationInMinute === c.durationInMinute && + b.prepTimeInMinute === c.prepTimeInMinute && + b.postProdTimeInMinute === c.postProdTimeInMinute + ); + }); +} + +export const BomProcessEditPanel = forwardRef( + function BomProcessEditPanel( + { detail, compareOldDetail = null, comparing = false, onDirtyChange }, + ref, + ) { + const { t } = useTranslation("importBom"); + const [isEditing, setIsEditing] = useState(false); + const [rows, setRows] = useState([]); + const [masterLoading, setMasterLoading] = useState(false); + const [masterError, setMasterError] = useState(null); + const [equipmentList, setEquipmentList] = useState([]); + const [processList, setProcessList] = useState([]); + const [bagItems, setBagItems] = useState([]); + const [byProductErrors, setByProductErrors] = useState>({}); + const startInFlightRef = useRef(false); + const byProductCheckInFlightRef = useRef>({}); + + const equipmentDescriptionOptions = useMemo(() => { + const s = new Set(); + equipmentList.forEach((e) => { + if (e.description) s.add(e.description); + }); + return Array.from(s).sort(); + }, [equipmentList]); + + useEffect(() => { + if (!isEditing) return; + onDirtyChange?.(!rowsMatchDetail(rows, detail, equipmentList, bagItems)); + }, [rows, isEditing, detail, equipmentList, bagItems, onDirtyChange]); + + const startEdit = useCallback(async () => { + if (startInFlightRef.current) return; + startInFlightRef.current = true; + setMasterLoading(true); + setMasterError(null); + try { + const [equipments, processes, bags] = await Promise.all([ + fetchAllEquipmentsMasterClient(), + fetchAllProcessesMasterClient(), + fetchBagItemsComboClient(), + ]); + setEquipmentList(equipments); + setProcessList(processes); + setBagItems(bags); + const next = (detail.processes ?? []).map((p, i) => { + const row = toEditRow(p, i, equipments, bags); + row.processName = + p.processName ?? processNameForCode(processes, row.processCode); + return row; + }); + setRows(next); + setIsEditing(true); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : t("Load process master failed"); + setMasterError(msg); + } finally { + setMasterLoading(false); + startInFlightRef.current = false; + } + }, [detail, t]); + + const cancelEdit = useCallback(() => { + setIsEditing(false); + setMasterError(null); + setByProductErrors({}); + onDirtyChange?.(false); + }, [onDirtyChange]); + + const checkByProductCode = useCallback( + async (rowKey: string, code: string): Promise => { + const trimmed = code.trim(); + if (!trimmed) { + setByProductErrors((prev) => { + const next = { ...prev }; + delete next[rowKey]; + return next; + }); + return null; + } + if (byProductCheckInFlightRef.current[rowKey]) return null; + byProductCheckInFlightRef.current[rowKey] = true; + try { + const result = await fetchItemExistsByCodeClient(trimmed); + if (!result.exists) { + const msg = t("Byproduct item not in master", { code: trimmed }); + setByProductErrors((prev) => ({ ...prev, [rowKey]: msg })); + return msg; + } + setByProductErrors((prev) => { + const next = { ...prev }; + delete next[rowKey]; + return next; + }); + return null; + } catch { + const msg = t("Byproduct item not in master", { code: trimmed }); + setByProductErrors((prev) => ({ ...prev, [rowKey]: msg })); + return msg; + } finally { + byProductCheckInFlightRef.current[rowKey] = false; + } + }, + [t], + ); + + const validate = useCallback((): string | null => { + if (!isEditing) return null; + for (const row of rows) { + const code = row.processCode.trim(); + if (!code) return t("Process code required"); + if (!processList.some((p) => p.code === code)) { + return t("Process not in master", { code }); + } + const ed = row.equipmentDescription.trim(); + const en = row.equipmentName.trim(); + if ((ed && !en) || (!ed && en)) { + return t("Equipment description and name both required"); + } + if (ed && en && !resolveEquipmentCode(equipmentList, ed, en)) { + return t("Equipment not in master", { pair: `${ed}-${en}` }); + } + if (isPackagingProcess(code)) { + for (const bagCode of row.selectedBagCodes) { + if (!bagItems.some((b) => b.code === bagCode)) { + return t("Bag item not in master", { code: bagCode }); + } + } + } + const bp = row.byProduct.trim(); + if (bp && byProductErrors[row.key]) { + return byProductErrors[row.key]; + } + } + return null; + }, [bagItems, byProductErrors, equipmentList, isEditing, processList, rows, t]); + + const validateAsync = useCallback(async (): Promise => { + const syncError = validate(); + if (syncError) return syncError; + for (const row of rows) { + const bp = row.byProduct.trim(); + if (!bp) continue; + const err = await checkByProductCode(row.key, bp); + if (err) return err; + } + return null; + }, [checkByProductCode, rows, validate]); + + useImperativeHandle( + ref, + () => ({ + isEditing: () => isEditing, + hasProcessChanges: () => + isEditing && !rowsMatchDetail(rows, detail, equipmentList, bagItems), + validate, + validateAsync, + getProcessSaveLines: () => + rows.map((r) => buildProcessSaveLine(r, equipmentList, bagItems)), + cancelEdit, + }), + [bagItems, cancelEdit, detail, equipmentList, isEditing, rows, validate, validateAsync], + ); + + const updateRow = useCallback((key: string, patch: Partial) => { + setRows((prev) => + prev.map((r) => { + if (r.key !== key) return r; + const next = { ...r, ...patch }; + if (patch.processCode != null) { + next.processName = processNameForCode(processList, patch.processCode); + if (!isPackagingProcess(patch.processCode)) { + next.selectedBagCodes = []; + } else { + next.description = ""; + } + } + if (patch.equipmentDescription != null && patch.equipmentName == null) { + const desc = patch.equipmentDescription.trim(); + if (!desc) { + next.equipmentName = ""; + } else { + const names = equipmentNamesForDescription(equipmentList, desc); + if (!names.includes(next.equipmentName)) { + next.equipmentName = ""; + } + } + } + return next; + }), + ); + }, [equipmentList, processList]); + + useEffect(() => { + cancelEdit(); + }, [detail.id, cancelEdit]); + + const renderEquipmentCell = (row: ProcessEditRow) => { + const hasDescription = Boolean(row.equipmentDescription.trim()); + const nameOptions = equipmentNamesForDescription( + equipmentList, + row.equipmentDescription, + ); + return ( + + + {t("Equipment Description")} + + + + {t("Equipment Name")} + + + + ); + }; + + const renderDescriptionCell = (row: ProcessEditRow) => { + if (isPackagingProcess(row.processCode)) { + return ( + + + + ); + } + return ( + updateRow(row.key, { description: e.target.value })} + sx={{ + ...editFieldSx, + minWidth: 180, + "& .MuiInputBase-input": { + whiteSpace: "normal", + wordBreak: "break-word", + }, + }} + /> + ); + }; + + const formatEquipmentDisplay = (p: BomProcessDto) => { + const desc = p.equipmentDescription?.trim(); + const name = p.equipmentName?.trim(); + if (desc && name) return `${desc} — ${name}`; + if (!p.equipmentCode?.trim()) return "-"; + return p.equipmentCode ?? "-"; + }; + + const formatProcessNameDisplay = (p: BomProcessDto) => + formatProcessLabel({ + code: p.processCode ?? "", + name: p.processName ?? p.processCode ?? "", + }); + + const oldProcessMap = useMemo( + () => buildOldProcessMap(compareOldDetail), + [compareOldDetail], + ); + + const formatDescriptionForDto = (p: BomProcessDto) => + formatProcessDescriptionDisplay(p.processDescription, p.processCode); + + const formatMinute = (v?: number | null) => + v == null || Number.isNaN(v) ? "-" : String(v); + + return ( + + + {t("Process & Equipment")} + {!isEditing ? ( + + ) : ( + + )} + + {masterError && ( + + {masterError} + + )} + + + + {t("Sequence")} + {t("Process Name")} + {t("Process Description")} + {t("Byproduct")} + {t("Equipment")} + {t("Duration (Minutes)")} + {t("Prep Time (Minutes)")} + {t("Post Prod Time (Minutes)")} + + + + {isEditing + ? rows.map((row) => ( + + {row.seqNo ?? "-"} + + + updateRow(row.key, { processCode: String(e.target.value) }) + } + sx={editFieldSx} + > + + {t("Select process")} + + {processList.map((p) => ( + + {formatProcessLabel(p)} + + ))} + + + + {renderDescriptionCell(row)} + + + { + const v = e.target.value.trim(); + updateRow(row.key, { byProduct: v }); + if (byProductErrors[row.key]) { + setByProductErrors((prev) => { + const next = { ...prev }; + delete next[row.key]; + return next; + }); + } + }} + onBlur={() => void checkByProductCode(row.key, row.byProduct)} + sx={editFieldSx} + /> + + {renderEquipmentCell(row)} + + + updateRow(row.key, { + durationInMinute: Number(e.target.value), + }) + } + sx={editFieldSx} + /> + + + + updateRow(row.key, { + prepTimeInMinute: Number(e.target.value), + }) + } + sx={editFieldSx} + /> + + + + updateRow(row.key, { + postProdTimeInMinute: Number(e.target.value), + }) + } + sx={editFieldSx} + /> + + + )) + : (detail.processes ?? []).map((p, i) => { + const oldP = + p.seqNo != null ? oldProcessMap.get(Number(p.seqNo)) : undefined; + return ( + + {p.seqNo} + + {processDiffCell(oldP, p, formatProcessNameDisplay, comparing)} + + + {processDiffCell(oldP, p, formatDescriptionForDto, comparing)} + + + {processDiffCell( + oldP, + p, + (row) => row.byProduct?.trim() || "-", + comparing, + )} + + + {processDiffCell(oldP, p, formatEquipmentDisplay, comparing)} + + + {processDiffCell( + oldP, + p, + (row) => formatMinute(row.durationInMinute), + comparing, + )} + + + {processDiffCell( + oldP, + p, + (row) => formatMinute(row.prepTimeInMinute), + comparing, + )} + + + {processDiffCell( + oldP, + p, + (row) => formatMinute(row.postProdTimeInMinute), + comparing, + )} + + + ); + })} + +
+
+ ); + }, +); diff --git a/src/components/ImportBom/BomPutawayLocationFields.tsx b/src/components/ImportBom/BomPutawayLocationFields.tsx new file mode 100644 index 0000000..11f4607 --- /dev/null +++ b/src/components/ImportBom/BomPutawayLocationFields.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +import { Grid, OutlinedInput, FormControl, InputLabel, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { PutawayLocationParts } from "./putawayLocationUtils"; + +type Props = { + value: PutawayLocationParts; + onChange: (next: PutawayLocationParts) => void; + error?: string | null; + disabled?: boolean; +}; + +export function BomPutawayLocationFields({ + value, + onChange, + error, + disabled = false, +}: Props) { + const { t } = useTranslation("importBom"); + + const field = (key: keyof PutawayLocationParts, label: string, width = 100) => ( + + + {label} + + onChange({ ...value, [key]: e.target.value })} + /> + + ); + + return ( + + + {field("floor", t("Putaway Floor"), 88)} + + + {field("warehouse", t("Putaway Warehouse"), 88)} + + + {field("area", t("Putaway Area"), 88)} + + + {field("slot", t("Putaway Slot"), 88)} + + {error ? ( + + + {error} + + + ) : null} + + ); +} diff --git a/src/components/ImportBom/BomVersionControlBar.tsx b/src/components/ImportBom/BomVersionControlBar.tsx new file mode 100644 index 0000000..8e5ced0 --- /dev/null +++ b/src/components/ImportBom/BomVersionControlBar.tsx @@ -0,0 +1,234 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Typography, +} from "@mui/material"; +import SaveIcon from "@mui/icons-material/Save"; +import type { BomVersionSummary } from "@/app/api/bom"; +import { fetchBomVersionsClient } from "@/app/api/bom/client"; +import { useTranslation } from "react-i18next"; +import { versionColorDotSx } from "./bomVersionCompare"; + +type Props = { + bomCode: string; + bomKind: string; + versionId: number | ""; + compareMode: boolean; + compareToId: number | ""; + saving: boolean; + versionsRefreshKey?: number; + saveDisabled: boolean; + saveError?: string | null; + onVersionChange: (id: number) => void; + onToggleCompare: () => void; + onCompareToChange: (id: number) => void; + onSave: () => void; +}; + +function versionLabel(v: BomVersionSummary): string { + const status = v.status === "active" ? "啟用" : "停用"; + return `V${v.revisionNo} (${status})`; +} + +function VersionMenuItem({ + v, + dotVariant, +}: { + v: BomVersionSummary; + dotVariant?: "old" | "new"; +}) { + return ( + + {dotVariant && } + {versionLabel(v)} + + ); +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-16 */ +export const BomVersionControlBar: React.FC = ({ + bomCode, + bomKind, + versionId, + compareMode, + compareToId, + saving, + versionsRefreshKey = 0, + saveDisabled, + saveError, + onVersionChange, + onToggleCompare, + onCompareToChange, + onSave, +}) => { + const { t } = useTranslation("importBom"); + const [versions, setVersions] = useState([]); + const [loadingVersions, setLoadingVersions] = useState(false); + + useEffect(() => { + if (!bomCode.trim()) return; + let cancelled = false; + const load = async () => { + setLoadingVersions(true); + try { + const list = await fetchBomVersionsClient(bomCode, bomKind || "FG"); + if (!cancelled) setVersions(list); + } finally { + if (!cancelled) setLoadingVersions(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [bomCode, bomKind, versionsRefreshKey]); + + const showCompareButton = versions.length >= 2; + const controlsDisabled = loadingVersions || saving; + const comparingTwo = + compareMode && + versionId !== "" && + compareToId !== "" && + versionId !== compareToId; + + const currentVersion = versions.find((v) => v.id === versionId); + const compareVersion = versions.find((v) => v.id === compareToId); + + const renderVersionValue = (id: number | "", dotVariant?: "old" | "new") => { + const v = versions.find((item) => item.id === id); + if (!v) return ""; + return ( + + {dotVariant && } + {versionLabel(v)} + + ); + }; + + return ( + + + + + {t("Version")} + + + + {showCompareButton && ( + + )} + + {compareMode && showCompareButton && ( + + {t("Compare Version")} + + + )} + + {loadingVersions && } + + + + + + + + {comparingTwo && currentVersion && compareVersion && ( + + + + + {t("Compare Version")}: V{compareVersion.revisionNo} + + + + + + {t("Version")}: V{currentVersion.revisionNo} + + + + )} + + {saveError && ( + + {saveError} + + )} + + ); +}; diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx index 3467f12..949b7b8 100644 --- a/src/components/ImportBom/ImportBomDetailTab.tsx +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -21,11 +21,14 @@ import { Checkbox, FormControlLabel, IconButton, + Grid, } from "@mui/material"; import type { BomCombo, BomDetailResponse, BomStatus } from "@/app/api/bom"; import { editBomClient, + saveBomAsNewVersionClient, fetchBomComboClient, + fetchBomVersionsClient, fetchBomDetailClient, fetchAllEquipmentsMasterClient, fetchAllProcessesMasterClient, @@ -40,6 +43,18 @@ 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( @@ -63,16 +78,31 @@ function resolveEquipmentCode( /** Full BOM field edit (materials/processes) — hidden until re-enabled. */ const SHOW_BOM_FULL_EDIT = false; +type BomSearchKey = "code" | "name"; +type BomSearchInputs = Record; + +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([]); const [selectedBomId, setSelectedBomId] = useState(""); const [detail, setDetail] = useState(null); const [loadingList, setLoadingList] = useState(false); - const [loadingDetail, setLoadingDetail] = useState(false); + const [versionsRefreshKey, setVersionsRefreshKey] = useState(0); const [filteredBoms, setFilteredBoms] = useState([]) const [currentBom, setCurrentBom] = useState(null); const loadDetailInFlightRef = useRef(false); + const saveInFlightRef = useRef(false); + const skipAutoSearchRef = useRef(false); + const detailIdRef = useRef(null); + const lastSearchInputsRef = useRef(EMPTY_BOM_SEARCH_INPUTS); type EditMaterialRow = { key: string; @@ -133,9 +163,18 @@ const ImportBomDetailTab: React.FC = () => { ProcessMasterRow[] >([]); const [editMasterLoading, setEditMasterLoading] = useState(false); - const [statusDraft, setStatusDraft] = useState("active"); - const [statusSaving, setStatusSaving] = useState(false); - const [statusError, setStatusError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [versionId, setVersionId] = useState(""); + const [compareMode, setCompareMode] = useState(false); + const [compareToId, setCompareToId] = useState(""); + const [compareOldDetail, setCompareOldDetail] = useState(null); + const materialEditRef = useRef(null); + const processEditRef = useRef(null); + const basicInfoRef = useRef(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<{ @@ -192,8 +231,6 @@ const ImportBomDetailTab: React.FC = () => { }; loadList(); }, []); - type BomSearchKey = "code" | "name"; - const searchCriteria: Criterion[] = useMemo( () => [ { label: t("Code"), paramName: "code", type: "text" }, @@ -204,44 +241,92 @@ const ImportBomDetailTab: React.FC = () => { 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) => { + async (id: number, options?: { resetVersionIds?: boolean }) => { 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); - setStatusDraft(d.status ?? "active"); - setStatusError(null); + setSaveError(null); + if (options?.resetVersionIds !== false) { + setVersionId(id); + setCompareMode(false); + setCompareToId(id); + setCompareOldDetail(null); + } } finally { - setLoadingDetail(false); 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: Record) => { + (inputs: BomSearchInputs) => { + lastSearchInputsRef.current = inputs; const code = (inputs.code ?? "").trim().toLowerCase(); const name = (inputs.name ?? "").trim().toLowerCase(); - - const result = bomList.filter((b) => { + + 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; }); - - setFilteredBoms(result); - - // 如果只找到一個,直接載入明細 - if (result.length === 1) { - void loadBomDetail(result[0].id); + + 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); @@ -250,77 +335,237 @@ const ImportBomDetailTab: React.FC = () => { }, [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 "不適用"; + + 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 renderBomStatus = (v?: BomStatus | string) => { - if (v === "active") return t("BOM Status Active"); - if (v === "inactive") return t("BOM Status Inactive"); - 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); - const handleSaveStatus = useCallback(async () => { - if (!detail?.id) return; - setStatusSaving(true); - setStatusError(null); try { - const updated = await editBomClient(detail.id, { status: statusDraft }); - setDetail(updated); - setStatusDraft(updated.status ?? statusDraft); - setBomList((prev) => - prev.map((b) => - b.id === updated.id ? { ...b, status: updated.status ?? statusDraft } : b, - ), + 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(), ); - setCurrentBom((prev) => - prev?.id === updated.id - ? { ...prev, status: updated.status ?? statusDraft } - : prev, + 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 update BOM status"; - setStatusError(message); + const message = e instanceof Error ? e.message : "Failed to save BOM"; + setSaveError(message); } finally { - setStatusSaving(false); + setSaving(false); + saveInFlightRef.current = false; } - }, [detail?.id, statusDraft]); - - const renderComplexity = (v?: number) => { - if (v === 10) return "簡單"; - if (v === 5) return "中度"; - if (v === 3) return "複雜"; - if (v === 0) return "不適用"; - return "-"; - }; + }, [ + detail, + headerOutputQtyUnresolved, + putawayLocationMissing, + refreshAfterSave, + t, + unresolvedMaterialIssueCount, + ]); + + 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); @@ -601,7 +846,6 @@ const ImportBomDetailTab: React.FC = () => { //onReset={handleResetBom} /> - {filteredBoms.length > 1 && ( @@ -613,7 +857,6 @@ const ImportBomDetailTab: React.FC = () => { key={b.id} size="small" variant={selectedBomId === b.id ? "contained" : "outlined"} - disabled={loadingDetail} onClick={() => void loadBomDetail(b.id)} > {String(b.label ?? b.id)} ({renderType(b.description)}, {renderBomStatus(b.status)}) @@ -622,695 +865,76 @@ const ImportBomDetailTab: React.FC = () => { )} - {loadingDetail && ( - - {t("Loading BOM Detail...")} - - )} - {detail && ( - + {detail.itemCode} {detail.itemName} - {/* Basic Info 列表 */} - - - - {t("Basic Info")} - - - {SHOW_BOM_FULL_EDIT && !isEditing ? ( - - ) : SHOW_BOM_FULL_EDIT ? ( - - - - - ) : null} - - - {editError && ( - - {editError} - - )} + {(currentBom?.code ?? detail.itemCode) && ( + { + setVersionId(id); + void loadBomDetail(id, { resetVersionIds: false }); + }} + onToggleCompare={() => void handleToggleCompare()} + onCompareToChange={setCompareToId} + onSave={() => void handleSave()} + /> + )} - {!isEditing && ( - - - - {t("BOM Status")} - - - - - {statusError && ( - - {statusError} + {unresolvedMaterialIssueMessage && ( + + {unresolvedMaterialIssueMessage} )} - {/* 第一行:輸出數量 + 類型 */} - - {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} - {" "} - {t("Type")}: {detail.description ?? "-"} - {" "} - {t("BOM Status")}: {renderBomStatus(detail.status)} - - - {/* 第二行:各種指標,排成一行 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 ?? "-"} - - - )} + {putawayLocationMissingWarn && ( + + {putawayLocationMissingWarn} + + )} - {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, - isPowderMixture: e.target.checked ? false : p.isPowderMixture, - } - : p - ) - } - /> - } - label={t("Is Drink")} + - - setEditBasic((p) => - p - ? { - ...p, - isPowderMixture: e.target.checked, - isDrink: e.target.checked ? false : p.isDrink, - } - : p - ) - } - /> - } - label={t("Powder_Mixture")} + + )} - - {/* 材料列表 */} - - - {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} - - - ))} - -
-
-
- )}
); }; diff --git a/src/components/ImportBom/ImportBomResultForm.tsx b/src/components/ImportBom/ImportBomResultForm.tsx index 20fcda4..30e8d1d 100644 --- a/src/components/ImportBom/ImportBomResultForm.tsx +++ b/src/components/ImportBom/ImportBomResultForm.tsx @@ -1,11 +1,10 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import TextField from "@mui/material/TextField"; import InputAdornment from "@mui/material/InputAdornment"; -import Checkbox from "@mui/material/Checkbox"; import Accordion from "@mui/material/Accordion"; import AccordionSummary from "@mui/material/AccordionSummary"; import AccordionDetails from "@mui/material/AccordionDetails"; @@ -13,130 +12,390 @@ import Typography from "@mui/material/Typography"; import Stack from "@mui/material/Stack"; import Paper from "@mui/material/Paper"; import CircularProgress from "@mui/material/CircularProgress"; +import Table from "@mui/material/Table"; +import TableHead from "@mui/material/TableHead"; +import TableBody from "@mui/material/TableBody"; +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import MenuItem from "@mui/material/MenuItem"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import Chip from "@mui/material/Chip"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import SearchIcon from "@mui/icons-material/Search"; -import type { BomFormatFileGroup } from "@/app/api/bom"; -import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import type { + BomFormatFileGroup, + BomImportItemOverrides, + BomImportPreviewLine, + BomProductType, +} from "@/app/api/bom"; +import { + bomProductTypeToImportFlags, + downloadBomFormatIssueLog, + fetchBomImportPreviewClient, + importBom, + revalidateImportBomItemClient, +} from "@/app/api/bom/client"; import { useTranslation } from "react-i18next"; -type CorrectItem = { - fileName: string; - isAlsoWip: boolean; - isDrink: boolean; - isPowderMixture: boolean; +import BomImportPreviewDialog, { + type BomImportPreviewEditResult, +} from "./BomImportPreviewDialog"; +import { + type BomImportKindMode, + bomKindModeFromImport, + bomKindModeLabel, + importFlagsFromBomKindMode, +} from "./bomImportKindMode"; + +type ImportableItem = { + fileName: string; + isAlsoWip: boolean; + productType: BomProductType; + preview?: BomImportPreviewLine; + overrides?: BomImportItemOverrides; +}; + +type IssueItem = ImportableItem & { + problems: string[]; +}; + +type ReviewTarget = { + fileName: string; + mode: "import" | "issue"; }; +const PRODUCT_TYPE_OPTIONS: BomProductType[] = ["drink", "powder_mixture", "other"]; +const BOM_KIND_MODE_OPTIONS: BomImportKindMode[] = ["FG", "WIP", "both"]; + type Props = { batchId: string; correctFileNames: string[]; failList: BomFormatFileGroup[]; uploadedCount: number; - issueLogFileId?: string; // 新增 + issueLogFileId?: string; onBack?: () => void; - }; - export default function ImportBomResultForm({ +}; + +function productTypeLabel(t: (key: string) => string, productType: BomProductType): string { + if (productType === "drink") return t("Drink"); + if (productType === "powder_mixture") return t("Powder_Mixture"); + return t("Other"); +} + +function displayField( + overrides: BomImportItemOverrides | undefined, + key: keyof BomImportItemOverrides, + fallback: T | null | undefined, +): T | null | undefined { + const v = overrides?.[key]; + if (v !== null && v !== undefined && v !== "") return v as T; + return fallback; +} + +function displayCodeName( + preview: BomImportPreviewLine | undefined, + overrides: BomImportItemOverrides | undefined, +): { code: string; name: string } { + const code = displayField(overrides, "code", preview?.code) ?? "-"; + const name = displayField(overrides, "name", preview?.name) ?? "-"; + return { code: String(code), name: String(name) }; +} + +/** Exclude server-generated format-issue log workbooks from import lists. */ +function isImportableBomFileName(fileName: string): boolean { + return !/^bom_format_issue_.*\.xlsx$/i.test(fileName); +} + +function upsertImportItem(items: ImportableItem[], item: ImportableItem): ImportableItem[] { + const itemCode = displayCodeName(item.preview, item.overrides).code; + const without = items.filter((x) => { + if (x.fileName === item.fileName) return false; + if (itemCode !== "-" && displayCodeName(x.preview, x.overrides).code === itemCode) { + return false; + } + return true; + }); + return [...without, item]; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-16 */ +export default function ImportBomResultForm({ batchId, correctFileNames, failList, uploadedCount, issueLogFileId, onBack, - }: Props) { - console.log("issueLogFileId from props", issueLogFileId); +}: Props) { const { t } = useTranslation(["importBom", "common", "importExcel"]); + const importableCorrectNames = useMemo( + () => correctFileNames.filter(isImportableBomFileName), + [correctFileNames], + ); + const importableFailList = useMemo( + () => failList.filter((f) => isImportableBomFileName(f.fileName)), + [failList], + ); const [search, setSearch] = useState(""); - const [items, setItems] = useState(() => - correctFileNames.map((fileName) => ({ + const [importItems, setImportItems] = useState(() => + importableCorrectNames.map((fileName) => ({ fileName, isAlsoWip: false, - isDrink: false, - isPowderMixture: fileName.includes("箱料粉"), - })) + productType: "other", + })), + ); + const [issueItems, setIssueItems] = useState(() => + importableFailList.map((f) => ({ + fileName: f.fileName, + isAlsoWip: false, + productType: "other", + problems: f.problems, + })), ); + const [reviewTarget, setReviewTarget] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); const [submitting, setSubmitting] = useState(false); const [successMsg, setSuccessMsg] = useState(null); + const [revalidating, setRevalidating] = useState(false); + const confirmInFlightRef = useRef(false); + const [downloadingLog, setDownloadingLog] = useState(false); - const filteredCorrect = useMemo(() => { - if (!search.trim()) return items; + const allFileNames = useMemo( + () => [ + ...importableCorrectNames, + ...importableFailList.map((f) => f.fileName), + ], + [importableCorrectNames, importableFailList], + ); + + useEffect(() => { + if (allFileNames.length === 0) return; + let cancelled = false; + (async () => { + setPreviewLoading(true); + setPreviewError(null); + try { + const res = await fetchBomImportPreviewClient(batchId, allFileNames); + if (cancelled) return; + const previewMap = new Map(res.items.map((line) => [line.fileName, line])); + setImportItems((prev) => { + const fromCorrect = importableCorrectNames.map((fileName) => { + const existing = prev.find((x) => x.fileName === fileName); + return { + fileName, + isAlsoWip: existing?.isAlsoWip ?? false, + productType: existing?.productType ?? "other", + preview: previewMap.get(fileName), + overrides: existing?.overrides, + }; + }); + const fixedExtras = prev.filter( + (x) => + isImportableBomFileName(x.fileName) && + !importableCorrectNames.includes(x.fileName), + ); + const byName = new Map(); + for (const item of [...fromCorrect, ...fixedExtras]) { + byName.set(item.fileName, item); + } + return Array.from(byName.values()); + }); + setIssueItems((prev) => + importableFailList.map((f) => { + const existing = prev.find((x) => x.fileName === f.fileName); + return { + fileName: f.fileName, + isAlsoWip: existing?.isAlsoWip ?? false, + productType: existing?.productType ?? "other", + preview: previewMap.get(f.fileName), + overrides: existing?.overrides, + problems: existing?.problems ?? f.problems, + }; + }), + ); + } catch (err) { + console.error(err); + if (!cancelled) { + setPreviewError(t("Preview load failed")); + } + } finally { + if (!cancelled) setPreviewLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [batchId, allFileNames, importableCorrectNames, importableFailList, t]); + + const filteredImport = useMemo(() => { + if (!search.trim()) return importItems; const q = search.trim().toLowerCase(); - return items.filter((i) => i.fileName.toLowerCase().includes(q)); - }, [items, search]); + return importItems.filter((item) => { + const p = item.preview; + const o = item.overrides; + const { code, name } = displayCodeName(p, o); + const haystack = [code, name, displayField(o, "bomKind", p?.bomKind)] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }); + }, [importItems, search]); - const handleToggleWip = (fileName: string) => { - setItems((prev) => - prev.map((x) => - x.fileName === fileName - ? { ...x, isAlsoWip: !x.isAlsoWip } - : x - ) + const reviewItem = + reviewTarget?.mode === "import" + ? importItems.find((x) => x.fileName === reviewTarget.fileName) + : issueItems.find((x) => x.fileName === reviewTarget?.fileName); + + const handleBomKindModeChange = (fileName: string, mode: BomImportKindMode) => { + const { isAlsoWip, bomKind } = importFlagsFromBomKindMode(mode); + setImportItems((prev) => + prev.map((x) => { + if (x.fileName !== fileName) return x; + return { + ...x, + isAlsoWip, + overrides: { + ...(x.overrides ?? {}), + bomKind, + }, + }; + }), ); }; - const handleToggleDrink = (fileName: string) => { - setItems((prev) => - prev.map((x) => - x.fileName === fileName - ? { ...x, isDrink: !x.isDrink, isPowderMixture: false } - : x - ) + + const handleProductTypeChange = (fileName: string, productType: BomProductType) => { + setImportItems((prev) => + prev.map((x) => (x.fileName === fileName ? { ...x, productType } : x)), ); }; - const handleTogglePowderMixture = (fileName: string) => { - setItems((prev) => + + const handleImportReviewSave = (fileName: string, result: BomImportPreviewEditResult) => { + setImportItems((prev) => prev.map((x) => x.fileName === fileName - ? { ...x, isPowderMixture: !x.isPowderMixture, isDrink: false } - : x - ) + ? { + ...x, + isAlsoWip: result.isAlsoWip, + productType: result.productType, + overrides: result.overrides, + } + : x, + ), ); }; + + const handleIssueReviewSave = async (fileName: string, result: BomImportPreviewEditResult) => { + if (revalidating) return; + setRevalidating(true); + try { + const res = await revalidateImportBomItemClient(batchId, fileName, result.overrides); + if (res.passed) { + setIssueItems((prev) => { + const moving = prev.find((x) => x.fileName === fileName); + if (moving) { + const nextItem: ImportableItem = { + fileName, + isAlsoWip: result.isAlsoWip, + productType: result.productType, + preview: moving.preview, + overrides: result.overrides, + }; + setImportItems((importPrev) => upsertImportItem(importPrev, nextItem)); + } + return prev.filter((x) => x.fileName !== fileName); + }); + setReviewTarget(null); + } else { + setIssueItems((prev) => + prev.map((x) => + x.fileName === fileName + ? { + ...x, + isAlsoWip: result.isAlsoWip, + productType: result.productType, + overrides: result.overrides, + problems: res.problems, + } + : x, + ), + ); + } + } catch (err) { + console.error(err); + setPreviewError(t("Revalidate failed")); + } finally { + setRevalidating(false); + } + }; + const handleDownloadIssueLog = async () => { - const blob = await downloadBomFormatIssueLog(batchId, issueLogFileId!); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; - a.click(); - URL.revokeObjectURL(url); + if (!issueLogFileId || downloadingLog) return; + setDownloadingLog(true); + try { + const blob = await downloadBomFormatIssueLog(batchId, issueLogFileId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setDownloadingLog(false); + } }; + const handleConfirm = async () => { + if (confirmInFlightRef.current) return; + confirmInFlightRef.current = true; setSubmitting(true); setSuccessMsg(null); try { - const blob = await importBom(batchId, items); - //const url = URL.createObjectURL(blob); - //const a = document.createElement("a"); - //a.href = url; - //a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; - //a.click(); - //URL.revokeObjectURL(url); - setSuccessMsg("匯入完成"); + const payload = importItems.map((item) => ({ + fileName: item.fileName, + isAlsoWip: item.isAlsoWip, + ...bomProductTypeToImportFlags(item.productType), + overrides: item.overrides, + })); + await importBom(batchId, payload); + setSuccessMsg(t("Import completed")); } catch (err) { console.error(err); - setSuccessMsg("匯入失敗,請查看主控台。"); + setSuccessMsg(t("Import failed")); } finally { setSubmitting(false); + confirmInFlightRef.current = false; } }; - const wipCount = items.filter((i) => i.isAlsoWip).length; - const powderMixtureCount = items.filter((i) => i.isPowderMixture).length; - const drinkCount = items.filter((i) => i.isDrink).length; - const totalChecked = correctFileNames.length + failList.length; + const wipCount = importItems.filter((i) => i.isAlsoWip).length; + const powderMixtureCount = importItems.filter((i) => i.productType === "powder_mixture").length; + const drinkCount = importItems.filter((i) => i.productType === "drink").length; + const otherCount = importItems.filter((i) => i.productType === "other").length; + const totalChecked = importItems.length + issueItems.length; return ( {onBack && ( )} - 已上傳 {uploadedCount} 個檔案,檢查結果共 {totalChecked} 筆:正確 {correctFileNames.length} 個、失敗 {failList.length} 個 + {t("Upload check summary", { + uploaded: uploadedCount, + total: totalChecked, + correct: importItems.length, + failed: issueItems.length, + })} {uploadedCount !== totalChecked && ( - 上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。 + {t("Upload count mismatch hint")} )} @@ -151,12 +410,20 @@ type Props = { }} > - - {t("Correct BOM List (Can Import)")} - + + + {t("Correct BOM List (Can Import)")} + + {previewLoading && } + + {previewError && ( + + {previewError} + + )} setSearch(e.target.value)} InputProps={{ @@ -168,112 +435,242 @@ type Props = { }} sx={{ mb: 2, width: "100%" }} /> - - - {t("WIP")} - {t("Drink")} - {t("Powder_Mixture")} - {t("File Name")} - - {filteredCorrect.map((item) => ( - - handleToggleWip(item.fileName)} - size="small" - /> - handleToggleDrink(item.fileName)} - size="small" - /> - handleTogglePowderMixture(item.fileName)} - size="small" - /> - - {item.fileName} - - - - ))} - + + + + + + {t("Bom Kind Mode")} + {t("Product Type")} + {t("Code")} + {t("Name")} + + + + {filteredImport.length === 0 ? ( + + + + {t("None")} + + + + ) : ( + filteredImport.map((item) => { + const p = item.preview; + const o = item.overrides; + const { code, name } = displayCodeName(p, o); + const hasEdits = Boolean(o); + const bomKindMode = bomKindModeFromImport( + item.isAlsoWip, + o?.bomKind ?? p?.bomKind, + ); + return ( + + + + + setReviewTarget({ + fileName: item.fileName, + mode: "import", + }) + } + color={hasEdits ? "primary" : "default"} + > + + + + + + + handleBomKindModeChange( + item.fileName, + e.target.value as BomImportKindMode, + ) + } + sx={{ minWidth: 96 }} + > + {BOM_KIND_MODE_OPTIONS.map((opt) => ( + + {bomKindModeLabel(t, opt)} + + ))} + + + + + handleProductTypeChange( + item.fileName, + e.target.value as BomProductType, + ) + } + sx={{ minWidth: 110 }} + > + {PRODUCT_TYPE_OPTIONS.map((opt) => ( + + {productTypeLabel(t, opt)} + + ))} + + + {code} + + + {name} + + + + ); + }) + )} + +
+
{t("Issue BOM List")} - {failList.length === 0 ? ( + {issueItems.length === 0 ? ( - 無 + {t("None")} ) : ( - failList.map((f) => ( - - }> - - {f.fileName} - - - - - {f.problems.map((p, i) => ( - - {p} + issueItems.map((item) => { + const { code, name } = displayCodeName(item.preview, item.overrides); + const hasEdits = Boolean(item.overrides); + return ( + + }> + + + {code} — {name} - ))} - - - - )) + + + { + e.stopPropagation(); + setReviewTarget({ + fileName: item.fileName, + mode: "issue", + }); + }} + color={hasEdits ? "primary" : "default"} + > + + + +
+ + + + {item.problems.map((p, i) => ( + + {p} + + ))} + + + + ); + }) )}
+ setReviewTarget(null)} + onSave={(result) => { + if (!reviewTarget) return; + if (reviewTarget.mode === "import") { + handleImportReviewSave(reviewTarget.fileName, result); + } else { + void handleIssueReviewSave(reviewTarget.fileName, result); + } + }} + onLiveFormatProblemsChange={(problems) => { + if (!reviewTarget || reviewTarget.mode !== "issue") return; + setIssueItems((prev) => + prev.map((x) => + x.fileName === reviewTarget.fileName ? { ...x, problems } : x, + ), + ); + }} + /> + - {submitting && } + {(submitting || revalidating) && } {successMsg && ( {successMsg} )} - {items.length > 0 && ( + {importItems.length > 0 && ( - 將匯入 {items.length} 個 BOM - {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 半成品` : ""} - {drinkCount > 0 ? `,${drinkCount} 個飲料` : ""} - {powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""} + {t("Import summary prefix", { count: importItems.length })} + {wipCount > 0 ? t("Import summary wip", { wip: wipCount }) : ""} + {drinkCount > 0 ? t("Import summary drink", { drink: drinkCount }) : ""} + {powderMixtureCount > 0 + ? t("Import summary powder", { powder: powderMixtureCount }) + : ""} + {otherCount > 0 ? t("Import summary other", { other: otherCount }) : ""} )} diff --git a/src/components/ImportBom/ImportBomUpload.tsx b/src/components/ImportBom/ImportBomUpload.tsx index ab48a9a..4520c0d 100644 --- a/src/components/ImportBom/ImportBomUpload.tsx +++ b/src/components/ImportBom/ImportBomUpload.tsx @@ -36,6 +36,7 @@ function getErrorMessage(err: unknown): string { return "上傳或檢查失敗,請稍後再試。"; } +/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-16 */ export default function ImportBomUpload({ onSuccess }: Props) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); diff --git a/src/components/ImportBom/bomAttributeScales.ts b/src/components/ImportBom/bomAttributeScales.ts new file mode 100644 index 0000000..cd5288c --- /dev/null +++ b/src/components/ImportBom/bomAttributeScales.ts @@ -0,0 +1,501 @@ +export const SCALE_RAIL_BG = "#e0e0e0"; +export const SCALE_THUMB_BORDER = "#9e9e9e"; + +/** Must match backend BomScaleValidation / validateBasicInfoSection. */ +export type BomScaleField = + | "isDark" + | "isFloat" + | "isDense" + | "timeSequence" + | "complexity" + | "allergicSubstances"; + +export const BOM_SCALE_ALLOWED: Record = { + isDark: [0, 1, 2, 3, 4, 5], + isFloat: [0, 3, 5], + isDense: [0, 3, 5], + timeSequence: [0, 1, 5], + complexity: [0, 3, 5, 10], + allergicSubstances: [0, 5], +}; + +const BOM_SCALE_LABELS: Record = { + isDark: "色深", + isFloat: "浮沉", + isDense: "濃淡", + timeSequence: "生產時段", + complexity: "生產複雜度", + allergicSubstances: "過敏原", +}; + +export function isAllowedScaleValue(field: BomScaleField, value: number): boolean { + return BOM_SCALE_ALLOWED[field].includes(value); +} + +export function formatAllowedScaleOptions(field: BomScaleField): string { + return BOM_SCALE_ALLOWED[field].join("、"); +} + +/** Import / revalidate: value must be in whitelist (0 = 不適用 is OK). */ +export function validateScaleAllowedForImport( + value: number, + field: BomScaleField, + fieldLabel = BOM_SCALE_LABELS[field], +): string | null { + if (!isAllowedScaleValue(field, value)) { + return `${fieldLabel}必須為 ${formatAllowedScaleOptions(field)}`; + } + return null; +} + +/** Edit BOM: whitelist + required non-zero for scored scale fields. */ +export function validateScaleAllowedForEdit( + value: number, + field: BomScaleField, + fieldLabel = BOM_SCALE_LABELS[field], +): string | null { + const allowedErr = validateScaleAllowedForImport(value, field, fieldLabel); + if (allowedErr) return allowedErr; + if ( + field === "allergicSubstances" || + field === "isFloat" || + field === "isDense" || + field === "timeSequence" || + field === "complexity" + ) { + return null; + } + if (value === 0) { + return `${fieldLabel}請設定數值,不可為不適用`; + } + if (field === "isDark" && (value < 1 || value > 5)) { + return `${fieldLabel}必須為 1~5`; + } + return null; +} + +export type ImportBasicScaleValues = { + isDark: number; + isFloat: number; + isDense: number; + timeSequence: number; + complexity: number; + allergicSubstances: number; +}; + +export function validateImportBasicScalesStrict( + values: ImportBasicScaleValues, +): string | null { + const checks: [BomScaleField, number][] = [ + ["isDark", values.isDark], + ["isFloat", values.isFloat], + ["isDense", values.isDense], + ["timeSequence", values.timeSequence], + ["complexity", values.complexity], + ["allergicSubstances", values.allergicSubstances], + ]; + for (const [field, val] of checks) { + const err = validateScaleAllowedForImport(val, field); + if (err) return err; + } + return null; +} + +export function localScaleFieldError( + field: BomScaleField, + value: number, +): string | undefined { + return validateScaleAllowedForImport(value, field) ?? undefined; +} + +/** Snap drag position to nearest allowed discrete value (e.g. 0 → 3 → 5). */ +export function snapToAllowedScale( + value: number, + allowed: readonly number[], +): number { + if (allowed.length === 0) return value; + let best = allowed[0]; + let minDist = Math.abs(value - best); + for (const step of allowed) { + const dist = Math.abs(value - step); + if (dist < minDist) { + minDist = dist; + best = step; + } + } + return best; +} + +export function allowedStepsForField( + field: BomScaleField, + options?: { includeNa?: boolean }, +): number[] { + const all = [...BOM_SCALE_ALLOWED[field]]; + if (options?.includeNa === false) { + return all.filter((v) => v !== 0); + } + return all; +} + +/** Edit form: coerce to nearest allowed step (invalid legacy → snap). */ +export function scaleValueForEditDiscrete( + value: number | null | undefined, + field: BomScaleField, + options?: { includeNa?: boolean }, +): number { + const steps = allowedStepsForField(field, options); + if (steps.length === 0) return 0; + if (value == null) return steps[0]; + if (options?.includeNa === false && value === 0) return steps[0]; + if (isAllowedScaleValue(field, value)) return value; + return snapToAllowedScale(value, steps); +} + +export type AttributeScaleConfig = { + min: number; + max: number; + lowLabel: string; + highLabel: string; + naValue?: number; + formatCaption: (value: number) => string; +}; + +export function isNaScaleValue(value: number | null | undefined, naValue = 0): boolean { + return value == null || value === naValue; +} + +/** Coerce API values; rejects booleans from legacy isDark responses. */ +export function normalizeScaleValue( + value: unknown, + config: AttributeScaleConfig, +): number | null { + if (typeof value === "boolean") return null; + if (typeof value !== "number" || Number.isNaN(value)) return null; + return clampScaleValue(value, config); +} + +export function clampScaleValue( + value: number | null | undefined, + config: AttributeScaleConfig, +): number | null { + if (value == null) return null; + if (config.naValue != null && value === config.naValue) return null; + if (value < config.min || value > config.max) return null; + return value; +} + +export function captionForAllowedScaleValue( + field: BomScaleField, + value: number, +): string { + if (value === 0 && field !== "allergicSubstances") return "不適用"; + switch (field) { + case "isDark": + return formatColorDepth(value) || String(value); + case "isFloat": + return formatFloatSink(value) || String(value); + case "isDense": + return formatDensity(value) || String(value); + case "timeSequence": + return formatTimeSequence(value) || String(value); + case "complexity": + return formatComplexity(value) || String(value); + case "allergicSubstances": + return formatAllergicSubstancesBasicInfo(value); + default: + return String(value); + } +} + +export function formatColorDepth(value: number): string { + if (value === 1) return "深"; + if (value === 5) return "浅"; + return ""; +} + +export function formatFloatSink(value: number): string { + if (value === 3) return "浮"; + if (value === 5) return "沉"; + return ""; +} + +export function formatDensity(value: number): string { + if (value === 3) return "濃"; + if (value === 5) return "淡"; + return ""; +} + +export function formatComplexity(value: number): string { + if (value === 10) return "簡單"; + if (value === 5) return "中度"; + if (value === 3) return "複雜"; + return ""; +} + +/** 色深 / 深浅:深 1 → 浅 5 */ +export const COLOR_DEPTH_SCALE: AttributeScaleConfig = { + min: 1, + max: 5, + lowLabel: "深", + highLabel: "浅", + naValue: 0, + formatCaption: formatColorDepth, +}; + +/** 浮沉:浮 3、沉 5;刻度 1–5 */ +export const FLOAT_SINK_SCALE: AttributeScaleConfig = { + min: 1, + max: 5, + lowLabel: "浮", + highLabel: "沉", + naValue: 0, + formatCaption: formatFloatSink, +}; + +/** 濃淡:濃 3、淡 5;刻度 1–5 */ +export const DENSITY_SCALE: AttributeScaleConfig = { + min: 1, + max: 5, + lowLabel: "濃", + highLabel: "淡", + naValue: 0, + formatCaption: formatDensity, +}; + +/** 複雜度:複雜 3 → 簡單 10;刻度 1–10 */ +export const COMPLEXITY_SCALE: AttributeScaleConfig = { + min: 1, + max: 10, + lowLabel: "複雜", + highLabel: "簡單", + naValue: 0, + formatCaption: formatComplexity, +}; + +export function formatTimeSequence(value: number): string { + if (value === 1) return "下午"; + if (value === 5) return "上午"; + return ""; +} + +/** 時段:下午 1 → 上午 5;刻度 1–5 */ +export const TIME_SEQUENCE_SCALE: AttributeScaleConfig = { + min: 1, + max: 5, + lowLabel: "下午", + highLabel: "上午", + naValue: 0, + formatCaption: formatTimeSequence, +}; + +export function scaleCaption( + value: number | null | undefined, + config: AttributeScaleConfig, +): string { + if (isNaScaleValue(value, config.naValue)) return "不適用"; + const v = value as number; + const ratio = `${v}/${config.max}`; + const label = config.formatCaption(v).trim(); + if (!label) return ratio; + return `${label}(${ratio})`; +} + +/** Label under discrete slider mark (0 → 不適用). */ +export function discreteStepLabel( + step: number, + config: AttributeScaleConfig, +): string { + if (step === 0) return "不適用"; + const text = config.formatCaption(step).trim(); + return text || String(step); +} + +/** Grid / compact display: numeric value only (0 / null → 不適用). */ +export function scaleValueOnly( + value: number | null | undefined, + config: AttributeScaleConfig, +): string { + if (isNaScaleValue(value, config.naValue)) return "不適用"; + return String(value); +} + +/** Import preview: preserve Excel raw values (including invalid 2, 4) for user correction. */ +export function scaleStateFromImport( + value: number | null | undefined, + _config: AttributeScaleConfig, +): number { + if (value == null) return 0; + if (typeof value === "number" && !Number.isNaN(value)) return value; + return 0; +} + +/** Import preview: null / 0 → 不適用 */ +export function scaleForImportEdit( + value: number | null | undefined, + config: AttributeScaleConfig, +): number | null { + if (value == null || value === 0 || value === (config.naValue ?? 0)) return null; + return scaleValueForEdit(value, config); +} + +/** Import preview save: 不適用 → 0 */ +export function scaleForImportSave( + value: number | null | undefined, + config: AttributeScaleConfig, +): number { + if (value == null || isNaScaleValue(value, config.naValue)) { + return config.naValue ?? 0; + } + return value; +} + +/** Default slider position when opening edit (0 / NA → min). */ +export function scaleValueForEdit( + value: number | null | undefined, + config: AttributeScaleConfig, +): number { + const normalized = normalizeScaleValue(value, config); + return normalized ?? config.min; +} + +/** @deprecated Prefer validateScaleAllowedForEdit with BomScaleField. */ +export function validateScaleForSave( + value: number, + config: AttributeScaleConfig, + fieldLabel: string, +): string | null { + if (value === 0 || isNaScaleValue(value, config.naValue)) { + return `${fieldLabel}請拖動刻度設定數值,不可為不適用`; + } + if (value < config.min || value > config.max) { + return `${fieldLabel}刻度無效`; + } + return null; +} + +/** Coerce API number fields (handles legacy string responses). */ +export function coerceBomScaleNumber(value: unknown): number | null { + if (typeof value === "number" && !Number.isNaN(value)) return value; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isNaN(n) ? null : n; + } + return null; +} + +/** Basic-info allergic display (matches BomBasicInfoSection). */ +export function formatAllergicSubstancesBasicInfo(value: number | null | undefined): string { + if (value === 0) return "有"; + if (value === 5) return "沒有"; + return "-"; +} + +/** Basic-info time sequence display (matches BomBasicInfoSection). */ +export function formatTimeSequenceBasicInfo(value: number | null | undefined): string { + if (value === 5) return "上午"; + if (value === 1) return "下午"; + if (value === 0) return "不適用"; + return "-"; +} + +export function formatScrapRateBasicInfo(value: number | null | undefined): string { + if (value == null || value === -1) return "不適用"; + return String(value); +} + +export type BomAttributeSummaryInput = { + isDark?: unknown; + isFloat?: unknown; + isDense?: unknown; + scrapRate?: unknown; + allergicSubstance?: unknown; + timeSequence?: unknown; + complexity?: unknown; +}; + +export type BomAttributeGridItem = { + labelKey: string; + value: string; +}; + +/** Label keys map to importBom.json (Depth, Float, Density, …). */ +export function buildBomAttributeGridItems( + attrs: BomAttributeSummaryInput, +): BomAttributeGridItem[] { + const isDark = coerceBomScaleNumber(attrs.isDark); + const isFloat = coerceBomScaleNumber(attrs.isFloat); + const isDense = coerceBomScaleNumber(attrs.isDense); + const scrapRate = coerceBomScaleNumber(attrs.scrapRate); + const allergic = coerceBomScaleNumber(attrs.allergicSubstance); + const timeSequence = coerceBomScaleNumber(attrs.timeSequence); + const complexity = coerceBomScaleNumber(attrs.complexity); + + return [ + { labelKey: "Depth label", value: scaleValueOnly(isDark, COLOR_DEPTH_SCALE) }, + { labelKey: "Float label", value: scaleValueOnly(isFloat, FLOAT_SINK_SCALE) }, + { labelKey: "Density label", value: scaleValueOnly(isDense, DENSITY_SCALE) }, + { labelKey: "Scrap Rate", value: formatScrapRateBasicInfo(scrapRate) }, + { labelKey: "Allergic Substances", value: formatAllergicSubstancesBasicInfo(allergic) }, + { labelKey: "Time Sequence label", value: scaleValueOnly(timeSequence, TIME_SEQUENCE_SCALE) }, + { labelKey: "Complexity label", value: scaleValueOnly(complexity, COMPLEXITY_SCALE) }, + ]; +} + +/** Job-order / process summary row — order matches BOM detail (浮沉 before 濃淡). */ +export function formatBomAttributeSummaryRow(attrs: BomAttributeSummaryInput): string { + const isDark = coerceBomScaleNumber(attrs.isDark); + const isFloat = coerceBomScaleNumber(attrs.isFloat); + const isDense = coerceBomScaleNumber(attrs.isDense); + const scrapRate = coerceBomScaleNumber(attrs.scrapRate); + const allergic = coerceBomScaleNumber(attrs.allergicSubstance); + const timeSequence = coerceBomScaleNumber(attrs.timeSequence); + const complexity = coerceBomScaleNumber(attrs.complexity); + + return [ + scaleCaption(isDark, COLOR_DEPTH_SCALE), + scaleCaption(isFloat, FLOAT_SINK_SCALE), + scaleCaption(isDense, DENSITY_SCALE), + formatScrapRateBasicInfo(scrapRate), + formatAllergicSubstancesBasicInfo(allergic), + formatTimeSequenceBasicInfo(timeSequence), + scaleCaption(complexity, COMPLEXITY_SCALE), + ].join(" | "); +} + +export function scaleSliderSx( + _config: AttributeScaleConfig, + compact = false, + discreteSteps = false, +) { + const thumbSize = compact ? 18 : 22; + const railHeight = compact ? 8 : 10; + return { + py: compact ? 0.5 : 1, + px: 0.5, + mb: discreteSteps ? 0 : undefined, + "& .MuiSlider-markLabel": { + display: "none", + }, + "& .MuiSlider-rail": { + height: railHeight, + borderRadius: railHeight / 2, + opacity: 1, + background: SCALE_RAIL_BG, + }, + "& .MuiSlider-track": { + display: "none", + }, + "& .MuiSlider-thumb": { + width: thumbSize, + height: thumbSize, + bgcolor: "#fff", + border: `3px solid ${SCALE_THUMB_BORDER}`, + boxShadow: "0 1px 4px rgba(0,0,0,0.25)", + "&:hover, &.Mui-focusVisible, &.Mui-active": { + boxShadow: "0 2px 6px rgba(0,0,0,0.3)", + }, + }, + } as const; +} diff --git a/src/components/ImportBom/bomDiff.tsx b/src/components/ImportBom/bomDiff.tsx new file mode 100644 index 0000000..0580f48 --- /dev/null +++ b/src/components/ImportBom/bomDiff.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React from "react"; +import { Box } from "@mui/material"; +import type { BomDetailResponse, BomMaterialDto, BomProcessDto } from "@/app/api/bom"; +import { compareNewSx, compareOldSx } from "./bomVersionCompare"; + +export { compareNewSx, compareOldSx } from "./bomVersionCompare"; + +/** @deprecated Use compareOldSx */ +export const diffOldSx = compareOldSx; + +/** @deprecated Use compareNewSx */ +export const diffNewSx = compareNewSx; + +export function InlineDiff({ oldVal, newVal }: { oldVal: string; newVal: string }) { + const o = oldVal.trim() || "-"; + const n = newVal.trim() || "-"; + if (o === n) { + return <>{n}; + } + return ( + <> + + {o} + + + {n} + + + ); +} + +export function DiffValue({ + comparing, + oldVal, + newVal, +}: { + comparing: boolean; + oldVal: string; + newVal: string; +}) { + if (!comparing) { + return <>{newVal}; + } + return ; +} + +export function fmtQtyUom(qty?: number | null, uom?: string | null): string { + if (qty == null || Number.isNaN(qty)) return "-"; + const u = (uom ?? "").trim(); + return u ? `${qty} ${u}` : String(qty); +} + +export function materialDiffCell( + o: BomMaterialDto | undefined, + n: BomMaterialDto | undefined, + pick: (m: BomMaterialDto) => string, + comparing: boolean, +) { + return entityDiffCell(o, n, pick, comparing); +} + +function entityDiffCell( + o: T | undefined, + n: T | undefined, + pick: (row: T) => string, + comparing: boolean, +) { + const newStr = n ? pick(n) : "-"; + if (!comparing) { + return <>{newStr}; + } + const oldStr = o ? pick(o) : "-"; + if (!o && n) { + return ( + + {newStr} + + ); + } + if (o && !n) { + return ( + + {oldStr} + + ); + } + return ; +} + +export function buildOldMaterialMap( + compareOldDetail: BomDetailResponse | null, +): Map { + if (!compareOldDetail) return new Map(); + return new Map(compareOldDetail.materials.map((m) => [m.itemCode ?? "", m])); +} + +export function buildOldProcessMap( + compareOldDetail: BomDetailResponse | null, +): Map { + if (!compareOldDetail) return new Map(); + return new Map( + (compareOldDetail.processes ?? []) + .filter((p) => p.seqNo != null) + .map((p) => [Number(p.seqNo), p]), + ); +} + +export function processDiffCell( + o: BomProcessDto | undefined, + n: BomProcessDto | undefined, + pick: (p: BomProcessDto) => string, + comparing: boolean, +) { + return entityDiffCell(o, n, pick, comparing); +} diff --git a/src/components/ImportBom/bomImportFormatFieldErrors.ts b/src/components/ImportBom/bomImportFormatFieldErrors.ts new file mode 100644 index 0000000..ebeb181 --- /dev/null +++ b/src/components/ImportBom/bomImportFormatFieldErrors.ts @@ -0,0 +1,205 @@ +import type { ImportMaterialEditRow } from "./bomImportPreviewUtils"; + +export type BasicFormatFieldKey = + | "code" + | "name" + | "outputQty" + | "outputQtyUom" + | "bomKindMode" + | "isDark" + | "isFloat" + | "isDense" + | "timeSequence" + | "complexity" + | "allergicSubstances"; + +export type MaterialFormatFieldKey = "itemCode" | "qty" | "uom" | "joinStep" | "general"; + +export type ProcessFormatFieldKey = "processCode"; + +export type ProcessRowFormatErrors = Partial>; + +export type MaterialRowFormatErrors = Partial>; + +export type MappedFormatProblems = { + basic: Partial>; + materialByRowKey: Record; + processByRowKey: Record; + hasMaterialIssues: boolean; + hasProcessIssues: boolean; +}; + +function pushMessage( + map: Partial>, + key: T, + message: string, +): Partial> { + const existing = map[key] ?? []; + if (existing.includes(message)) return map; + return { ...map, [key]: [...existing, message] }; +} + +function pushProcessMessage( + map: Record, + rowKey: string, + field: ProcessFormatFieldKey, + message: string, +): Record { + const row = map[rowKey] ?? {}; + const existing = row[field] ?? []; + if (existing.includes(message)) return map; + return { + ...map, + [rowKey]: { ...row, [field]: [...existing, message] }, + }; +} + +function resolveProcessRowKey( + problem: string, + processRows: { key: string; seqNo?: number }[], +): string | null { + const rowMatch = problem.match(/第(\d+)行/); + if (rowMatch) { + const seq = Number(rowMatch[1]); + if (Number.isFinite(seq)) { + const bySeq = processRows.find((r) => r.seqNo === seq); + if (bySeq) return bySeq.key; + } + } + const seqMatch = problem.match(/次序(\d+)/); + if (seqMatch) { + const seq = Number(seqMatch[1]); + const bySeq = processRows.find((r) => r.seqNo === seq); + if (bySeq) return bySeq.key; + } + return null; +} + +function pushMaterialMessage( + map: Record, + rowKey: string, + field: MaterialFormatFieldKey, + message: string, +): Record { + const row = map[rowKey] ?? {}; + const existing = row[field] ?? []; + if (existing.includes(message)) return map; + return { + ...map, + [rowKey]: { ...row, [field]: [...existing, message] }, + }; +} + +function extractItemCode(problem: string): string | null { + const byMaterial = problem.match(/『材料編號』\(([^)]+)\)/); + if (byMaterial?.[1]) return byMaterial[1].trim(); + const byItem = problem.match(/貨品\(([^)]+)\)/); + if (byItem?.[1]) return byItem[1].trim(); + return null; +} + +function resolveMaterialRowKey( + problem: string, + materialRows: ImportMaterialEditRow[], +): string | null { + const itemCode = extractItemCode(problem); + if (itemCode) { + const byCode = materialRows.find((r) => r.itemCode.trim() === itemCode); + if (byCode) return byCode.key; + } + const rowMatch = problem.match(/第(\d+)行/); + if (rowMatch) { + const excelRow = Number(rowMatch[1]); + if (Number.isFinite(excelRow) && excelRow > 0) { + // Prefer matching by current typed codes first already done; fall back to order index + // when Excel row number equals 1-based material list position (revalidate after edit). + if (excelRow <= materialRows.length) { + return materialRows[excelRow - 1]?.key ?? null; + } + } + } + return null; +} + +function classifyMaterialField(problem: string): MaterialFormatFieldKey { + if (problem.includes("『材料編號』")) return "itemCode"; + if (problem.includes("『使用份量』")) return "qty"; + if ( + problem.includes("『使用單位』") || + problem.includes("Base Unit") || + problem.includes("無法換算") || + problem.includes("matrix") + ) { + return "uom"; + } + if (problem.includes("『加入步驟』")) return "joinStep"; + return "general"; +} + +function classifyBasicField(problem: string): BasicFormatFieldKey | null { + if (!problem.includes("基本資料")) return null; + if (problem.includes("『編號』")) return "code"; + if (problem.includes("份量 (Qty)") || problem.includes("『份量』")) return "outputQty"; + if (problem.includes("『單位』")) return "outputQtyUom"; + if (problem.includes("『種類』")) return "bomKindMode"; + if (problem.includes("『過敏原』")) return "allergicSubstances"; + if (problem.includes("顔色深淺度") || problem.includes("颜色深浅度")) return "isDark"; + if (problem.includes("『浮沉』")) return "isFloat"; + if (problem.includes("濃淡程度") || problem.includes("浓淡程度")) return "isDense"; + if (problem.includes("生產時段先後數值") || problem.includes("生产时段先后数值")) { + return "timeSequence"; + } + if (problem.includes("生產複雜度") || problem.includes("生产复杂度")) return "complexity"; + return null; +} + +export function mapFormatProblemsToFields( + problems: string[], + materialRows: ImportMaterialEditRow[], + processRows: { key: string; seqNo?: number }[] = [], +): MappedFormatProblems { + let basic: Partial> = {}; + let materialByRowKey: Record = {}; + let processByRowKey: Record = {}; + let hasMaterialIssues = false; + let hasProcessIssues = false; + + for (const problem of problems) { + if (problem.includes("材料區")) { + hasMaterialIssues = true; + const rowKey = resolveMaterialRowKey(problem, materialRows); + if (rowKey) { + const field = classifyMaterialField(problem); + materialByRowKey = pushMaterialMessage(materialByRowKey, rowKey, field, problem); + } + continue; + } + if (problem.includes("工序區") || problem.includes("製程")) { + hasProcessIssues = true; + const rowKey = resolveProcessRowKey(problem, processRows); + if (rowKey) { + processByRowKey = pushProcessMessage( + processByRowKey, + rowKey, + "processCode", + problem, + ); + } + continue; + } + const basicField = classifyBasicField(problem); + if (basicField) { + basic = pushMessage(basic, basicField, problem); + } + } + + return { basic, materialByRowKey, processByRowKey, hasMaterialIssues, hasProcessIssues }; +} + +export function firstFieldError(messages: string[] | undefined): string | undefined { + return messages?.[0]; +} + +export function hasFieldError(messages: string[] | undefined): boolean { + return Boolean(messages?.length); +} diff --git a/src/components/ImportBom/bomImportKindMode.ts b/src/components/ImportBom/bomImportKindMode.ts new file mode 100644 index 0000000..be610f5 --- /dev/null +++ b/src/components/ImportBom/bomImportKindMode.ts @@ -0,0 +1,34 @@ +export type BomImportKindMode = "FG" | "WIP" | "both"; + +export function normalizeImportBomKind(raw?: string | null): "FG" | "WIP" { + const upper = raw?.trim().toUpperCase(); + return upper === "WIP" ? "WIP" : "FG"; +} + +export function bomKindModeFromImport( + isAlsoWip: boolean, + bomKind?: string | null, +): BomImportKindMode { + const kind = normalizeImportBomKind(bomKind); + if (isAlsoWip && kind === "FG") return "both"; + if (kind === "WIP") return "WIP"; + return "FG"; +} + +export function importFlagsFromBomKindMode(mode: BomImportKindMode): { + isAlsoWip: boolean; + bomKind: "FG" | "WIP"; +} { + if (mode === "WIP") return { isAlsoWip: false, bomKind: "WIP" }; + if (mode === "both") return { isAlsoWip: true, bomKind: "FG" }; + return { isAlsoWip: false, bomKind: "FG" }; +} + +export function bomKindModeLabel( + t: (key: string) => string, + mode: BomImportKindMode, +): string { + if (mode === "WIP") return t("Bom Kind WIP"); + if (mode === "both") return t("Bom Kind Both"); + return t("Bom Kind FG"); +} diff --git a/src/components/ImportBom/bomImportPreviewUtils.ts b/src/components/ImportBom/bomImportPreviewUtils.ts new file mode 100644 index 0000000..aad7466 --- /dev/null +++ b/src/components/ImportBom/bomImportPreviewUtils.ts @@ -0,0 +1,377 @@ +import type { + BomImportPreviewMaterialLine, + BomImportPreviewProcessLine, + BomMaterialUomOption, +} from "@/app/api/bom"; +import type { EquipmentMasterRow, ProcessMasterRow } from "@/app/api/bom/client"; +import { + fetchItemExistsByCodeClient, + fetchItemsExistsByCodesClient, +} from "@/app/api/bom/client"; +import { buildProcessSaveLine } from "./bomVersionSaveUtils"; +import { + equipmentNamesForDescription, + isPackagingProcess, + parsePackagingBagCodes, + resolveEquipmentCode, + type BagItemCombo, +} from "./bomProcessUtils"; +import type { ProcessEditRow } from "./BomProcessEditPanel"; + +export type ImportMaterialEditRow = { + key: string; + itemCode: string; + itemName: string; + itemId?: number; + qty: number; + uomId: number; + uomCode: string; + salesUomId?: number; + stockUomId?: number; + baseUomId?: number; + previewBaseQty?: number; + previewStockQty?: number; + baseUom?: string; + stockUom?: string; + joinStepSeqNo: number | null; + availableRecipeUoms: BomMaterialUomOption[]; +}; + +function normalizeUomToken(code: string): string { + return code.trim().toUpperCase(); +} + +function resolveRecipeUomId(m: BomImportPreviewMaterialLine): number { + if (m.uomId) return m.uomId; + const excelCode = normalizeUomToken(m.uomCode ?? ""); + const fromOptions = m.availableRecipeUoms?.find((u) => { + const optCode = normalizeUomToken(u.code ?? ""); + const optLabel = normalizeUomToken(u.label ?? ""); + return optCode === excelCode || optLabel === excelCode; + }); + if (fromOptions) return fromOptions.uomId; + return m.availableRecipeUoms?.[0]?.uomId ?? 0; +} + +export function previewMaterialToEditRow( + m: BomImportPreviewMaterialLine, + index: number, +): ImportMaterialEditRow { + const uomId = resolveRecipeUomId(m); + const uomCode = + m.availableRecipeUoms?.find((u) => u.uomId === uomId)?.code ?? + (m.uomCode?.trim() || ""); + return { + key: `${index}-${m.itemCode ?? "row"}`, + itemCode: m.itemCode?.trim() ?? "", + itemName: m.itemName?.trim() ?? "", + itemId: m.itemId ?? undefined, + qty: m.qty ?? 0, + uomId, + uomCode, + salesUomId: m.salesUomId ?? undefined, + stockUomId: m.stockUomId ?? undefined, + baseUomId: m.baseUomId ?? undefined, + previewBaseQty: m.baseQty ?? undefined, + previewStockQty: m.stockQty ?? undefined, + baseUom: m.baseUom ?? undefined, + stockUom: m.stockUom ?? undefined, + joinStepSeqNo: m.joinStepSeqNo ?? null, + availableRecipeUoms: m.availableRecipeUoms ?? [], + }; +} + +export function resolveEquipmentFromLegacyLabel( + list: EquipmentMasterRow[], + legacy: string, +): { equipmentDescription: string; equipmentName: string } { + const trimmed = legacy.trim(); + if (!trimmed) return { equipmentDescription: "", equipmentName: "" }; + if (trimmed === "不適用" || trimmed === "不合用") { + return { equipmentDescription: "", equipmentName: "" }; + } + const byCode = list.find((e) => e.code === trimmed); + if (byCode) { + return { + equipmentDescription: byCode.description ?? "", + equipmentName: byCode.name ?? "", + }; + } + const dash = trimmed.indexOf("-"); + if (dash > 0) { + return { + equipmentDescription: trimmed.substring(0, dash).trim(), + equipmentName: trimmed.substring(dash + 1).trim(), + }; + } + return { equipmentDescription: "", equipmentName: trimmed }; +} + +export function materialRowsFromOverrideOrPreview( + overrideMaterials: BomImportPreviewMaterialLine[] | undefined, + previewMaterials: BomImportPreviewMaterialLine[] | undefined, +): ImportMaterialEditRow[] { + const previewByCode = new Map( + (previewMaterials ?? []).map((m) => [m.itemCode?.trim() ?? "", m]), + ); + const source = overrideMaterials ?? previewMaterials ?? []; + return source.map((m, i) => { + const base = previewByCode.get(m.itemCode?.trim() ?? "") ?? m; + return previewMaterialToEditRow( + { + ...base, + ...m, + qty: m.qty ?? base.qty, + uomId: m.uomId ?? base.uomId, + uomCode: m.uomCode ?? base.uomCode, + joinStepSeqNo: m.joinStepSeqNo ?? base.joinStepSeqNo, + }, + i, + ); + }); +} + +export function previewProcessToEditRow( + p: BomImportPreviewProcessLine, + index: number, + processList: ProcessMasterRow[], + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], +): ProcessEditRow { + let processCode = (p.processCode ?? "").trim(); + const importedName = (p.processName ?? "").trim(); + if (!processCode && importedName) { + processCode = + processList.find((x) => x.code === importedName || x.name === importedName)?.code ?? ""; + } + let equipmentDescription = (p.equipmentDescription ?? "").trim(); + let equipmentName = (p.equipmentName ?? "").trim(); + if ( + (equipmentDescription === "不適用" || equipmentDescription === "不合用") && + !equipmentName + ) { + equipmentDescription = ""; + } + if (!equipmentDescription && equipmentName) { + const split = resolveEquipmentFromLegacyLabel(equipmentList, equipmentName); + equipmentDescription = split.equipmentDescription; + equipmentName = split.equipmentName; + } + const description = (p.description ?? "").trim(); + return { + key: `${p.seqNo ?? index}-${processCode || index}`, + seqNo: p.seqNo != null ? Number(p.seqNo) : undefined, + processCode, + processName: processList.find((x) => x.code === processCode)?.name ?? importedName, + importedProcessName: !processCode && importedName ? importedName : undefined, + description: isPackagingProcess(processCode) ? "" : description, + byProduct: p.byProduct?.trim() ?? "", + byProductUom: p.byProductUom?.trim() ?? "", + equipmentDescription, + equipmentName, + selectedBagCodes: isPackagingProcess(processCode) + ? parsePackagingBagCodes(description) + : [], + durationInMinute: p.durationInMinute ?? 0, + prepTimeInMinute: p.prepTimeInMinute ?? 0, + postProdTimeInMinute: p.postProdTimeInMinute ?? 0, + }; +} + +export function processRowsFromOverrideOrPreview( + overrideProcesses: BomImportPreviewProcessLine[] | undefined, + previewProcesses: BomImportPreviewProcessLine[] | undefined, + processList: ProcessMasterRow[], + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], +): ProcessEditRow[] { + const source = overrideProcesses ?? previewProcesses ?? []; + return source.map((p, i) => + previewProcessToEditRow(p, i, processList, equipmentList, bagItems), + ); +} + +export function materialRowsToOverride( + rows: ImportMaterialEditRow[], +): BomImportPreviewMaterialLine[] { + return rows.map((r) => { + const uomCode = + r.uomCode.trim() || + r.availableRecipeUoms.find((u) => u.uomId === r.uomId)?.code || + null; + return { + itemCode: r.itemCode.trim() || null, + itemName: r.itemName.trim() || null, + itemId: r.itemId ?? null, + qty: r.qty, + uomId: r.uomId || null, + uomCode, + joinStepSeqNo: r.joinStepSeqNo, + }; + }); +} + +export function processRowsToOverride( + rows: ProcessEditRow[], + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], +): BomImportPreviewProcessLine[] { + return rows.map((r) => { + const line = buildProcessSaveLine(r, equipmentList, bagItems); + return { + seqNo: line.seqNo ?? null, + processCode: line.processCode, + processName: r.processName || null, + description: line.description ?? null, + byProduct: line.byProduct ?? null, + byProductUom: line.byProductUom ?? null, + equipmentDescription: line.equipmentDescription ?? null, + equipmentName: line.equipmentName ?? null, + durationInMinute: line.durationInMinute ?? 0, + prepTimeInMinute: line.prepTimeInMinute ?? 0, + postProdTimeInMinute: line.postProdTimeInMinute ?? 0, + }; + }); +} + +export function validateImportBasicFields( + t: (key: string) => string, + fields: { + code: string; + name: string; + outputQty: string; + }, +): string | null { + if (!fields.code.trim()) return t("Import code required"); + // Name is resolved from Items by code; Excel 產品名稱 is not required. + const qty = Number(fields.outputQty.trim()); + if (!fields.outputQty.trim() || Number.isNaN(qty) || qty <= 0) { + return t("Import output qty required"); + } + return null; +} + +export function validateImportMaterialRows( + t: (key: string) => string, + rows: ImportMaterialEditRow[], + processSeqNos: number[], +): string | null { + for (const row of rows) { + const code = row.itemCode.trim(); + if (!code) return t("Material item code required"); + if (!row.uomId) return t("Material uom required"); + if (row.qty == null || Number.isNaN(row.qty) || row.qty <= 0) { + return t("Material qty required"); + } + if ( + row.joinStepSeqNo != null && + !processSeqNos.includes(row.joinStepSeqNo) + ) { + return t("Join step not in process list"); + } + } + return null; +} + +export function validateImportProcessRows( + t: (key: string, options?: Record) => string, + rows: ProcessEditRow[], + processList: ProcessMasterRow[], + equipmentList: EquipmentMasterRow[], + bagItems: BagItemCombo[], + byProductErrors: Record, +): string | null { + for (const row of rows) { + const code = row.processCode.trim(); + if (!code) { + const label = row.importedProcessName?.trim() || row.processName?.trim(); + if (label) { + return t("Import process not in master", { name: label }); + } + return t("Process code required"); + } + if (!processList.some((p) => p.code === code)) { + return t("Process not in master", { code }); + } + const ed = row.equipmentDescription.trim(); + const en = row.equipmentName.trim(); + if ((ed && !en) || (!ed && en)) { + return t("Equipment description and name both required"); + } + if (ed && en && !resolveEquipmentCode(equipmentList, ed, en)) { + return t("Equipment not in master", { pair: `${ed}-${en}` }); + } + if (isPackagingProcess(code)) { + for (const bagCode of row.selectedBagCodes) { + if (!bagItems.some((b) => b.code === bagCode)) { + return t("Bag item not in master", { code: bagCode }); + } + } + } + const bp = row.byProduct.trim(); + if (bp && byProductErrors[row.key]) { + return byProductErrors[row.key]; + } + } + return null; +} + +export async function validateImportMaterialRowsAsync( + t: (key: string, options?: Record) => string, + rows: ImportMaterialEditRow[], +): Promise { + const codesInOrder = rows + .map((row) => row.itemCode.trim()) + .filter((code) => !!code); + + if (codesInOrder.length === 0) return null; + + const uniqueCodes = Array.from(new Set(codesInOrder)); + + try { + const results = await fetchItemsExistsByCodesClient(uniqueCodes); + const existsByCode = new Map( + results.map((r) => [r.code, r.exists] as const), + ); + + for (const row of rows) { + const code = row.itemCode.trim(); + if (!code) continue; + if (!existsByCode.get(code)) { + return t("Material item not in master", { code }); + } + } + + return null; + } catch { + // Fallback: keep the old behavior if batch API fails. + for (const row of rows) { + const code = row.itemCode.trim(); + if (!code) continue; + try { + const result = await fetchItemExistsByCodeClient(code); + if (!result.exists) { + return t("Material item not in master", { code }); + } + } catch { + return t("Material item not in master", { code }); + } + } + return null; + } +} + +export async function validateImportProcessRowsAsync( + rows: ProcessEditRow[], + checkByProduct: (rowKey: string, code: string) => Promise, +): Promise { + for (const row of rows) { + const bp = row.byProduct.trim(); + if (!bp) continue; + const err = await checkByProduct(row.key, bp); + if (err) return err; + } + return null; +} + +export { equipmentNamesForDescription }; diff --git a/src/components/ImportBom/bomProcessUtils.ts b/src/components/ImportBom/bomProcessUtils.ts new file mode 100644 index 0000000..f647222 --- /dev/null +++ b/src/components/ImportBom/bomProcessUtils.ts @@ -0,0 +1,115 @@ +import type { EquipmentMasterRow } from "@/app/api/bom/client"; + +/** Fixed `process.code` for packaging (工序說明 = bag items). */ +export const PACKAGING_PROCESS_CODE = "包裝"; + +export type BagItemCombo = { code: string; name: string }; + +/** Resolve equipment.code from master description + name pair. */ +export 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; +} + +export function formatProcessLabel( + process: { code: string; name: string }, +): string { + const code = process.code.trim(); + const name = process.name.trim(); + if (!code) return name || "-"; + if (!name || name === code) return code; + return `${name} (${code})`; +} + +export function formatProcessDescriptionDisplay( + description?: string | null, + processCode?: string | null, +): string { + if (!description?.trim()) return "-"; + if (isPackagingProcess(processCode)) { + return description + .split("/") + .map((s) => s.trim()) + .filter(Boolean) + .join("\n"); + } + return description; +} + +export function isPackagingProcess(processCode?: string | null): boolean { + return (processCode ?? "").trim() === PACKAGING_PROCESS_CODE; +} + +export function parsePackagingBagCodes(description?: string | null): string[] { + if (!description?.trim()) return []; + return description + .split("/") + .map((s) => s.trim()) + .filter(Boolean) + .map((seg) => { + const dash = seg.indexOf("-"); + return dash > 0 ? seg.substring(0, dash).trim() : ""; + }) + .filter(Boolean); +} + +export function joinPackagingDescription( + bagCodes: string[], + bagItems: BagItemCombo[], +): string { + const byCode = new Map(bagItems.map((b) => [b.code, b.name])); + return bagCodes + .map((code) => { + const name = byCode.get(code) ?? ""; + return name ? `${code}-${name}` : code; + }) + .filter(Boolean) + .join("/"); +} + +export function equipmentNamesForDescription( + list: EquipmentMasterRow[], + description: string, +): string[] { + const d = description.trim(); + if (!d) return []; + const names = new Set(); + list.filter((e) => e.description === d).forEach((e) => { + if (e.name) names.add(e.name); + }); + return Array.from(names).sort(); +} + +export function splitEquipmentFromDetail( + equipmentList: EquipmentMasterRow[], + equipmentCode?: string | null, +): { equipmentDescription: string; equipmentName: string } { + const code = (equipmentCode ?? "").trim(); + if (!code) return { equipmentDescription: "", equipmentName: "" }; + const eq = equipmentList.find((e) => e.code === code); + if (eq) { + return { + equipmentDescription: eq.description ?? "", + equipmentName: eq.name ?? "", + }; + } + const dash = code.indexOf("-"); + if (dash > 0) { + return { + equipmentDescription: code.substring(0, dash), + equipmentName: code.substring(dash + 1), + }; + } + return { equipmentDescription: "", equipmentName: code }; +} diff --git a/src/components/ImportBom/bomVersionCompare.ts b/src/components/ImportBom/bomVersionCompare.ts new file mode 100644 index 0000000..1e92c46 --- /dev/null +++ b/src/components/ImportBom/bomVersionCompare.ts @@ -0,0 +1,43 @@ +/** Compare-version (对比版本 / diff old) — indigo */ +export const COMPARE_OLD_COLOR = { + color: "#3949ab", + bg: "#e8eaf6", + border: "#3949ab", +} as const; + +/** Current version (版本 / diff new) — amber */ +export const COMPARE_NEW_COLOR = { + color: "#e65100", + bg: "#fff3e0", + border: "#e65100", +} as const; + +export const compareOldSx = { + color: COMPARE_OLD_COLOR.color, + bgcolor: COMPARE_OLD_COLOR.bg, + px: 0.5, + py: 0.25, + borderRadius: 0.5, + mr: 0.5, + fontWeight: 600, +} as const; + +export const compareNewSx = { + color: COMPARE_NEW_COLOR.color, + bgcolor: COMPARE_NEW_COLOR.bg, + px: 0.5, + py: 0.25, + borderRadius: 0.5, + fontWeight: 600, +} as const; + +export function versionColorDotSx(variant: "old" | "new") { + const c = variant === "old" ? COMPARE_OLD_COLOR : COMPARE_NEW_COLOR; + return { + width: 10, + height: 10, + borderRadius: "50%", + bgcolor: c.border, + flexShrink: 0, + } as const; +} diff --git a/src/components/ImportBom/bomVersionSaveUtils.ts b/src/components/ImportBom/bomVersionSaveUtils.ts new file mode 100644 index 0000000..9eee02d --- /dev/null +++ b/src/components/ImportBom/bomVersionSaveUtils.ts @@ -0,0 +1,100 @@ +import type { BomDetailResponse } from "@/app/api/bom"; +import type { + BomMaterialVersionEditLine, + BomProcessVersionEditLine, +} from "@/app/api/bom"; +import { + isPackagingProcess, + joinPackagingDescription, + parsePackagingBagCodes, + resolveEquipmentCode, + splitEquipmentFromDetail, +} from "./bomProcessUtils"; +import type { BagItemCombo } from "./bomProcessUtils"; + +export function materialVersionLinesFromDetail( + detail: BomDetailResponse, +): BomMaterialVersionEditLine[] { + return detail.materials + .filter((m) => m.itemCode && m.recipeUomId != null) + .map((m) => ({ + sourceBomMaterialId: m.id, + itemCode: m.itemCode!, + qty: Number(m.recipeQty ?? m.baseQty ?? 0), + uomId: m.recipeUomId!, + bomProcessIds: m.processStepIds ?? [], + })); +} + +export function processVersionLinesFromDetail( + detail: BomDetailResponse, + equipmentList: Array<{ code: string; name: string; description: string }>, + bagItems: BagItemCombo[], +): BomProcessVersionEditLine[] { + return (detail.processes ?? []).map((p) => { + const { equipmentDescription, equipmentName } = splitEquipmentFromDetail( + equipmentList, + p.equipmentCode, + ); + const processCode = (p.processCode ?? "").trim(); + const description = isPackagingProcess(processCode) + ? joinPackagingDescription( + parsePackagingBagCodes(p.processDescription), + bagItems, + ) + : (p.processDescription ?? ""); + return { + seqNo: p.seqNo, + processCode, + description, + byProduct: p.byProduct?.trim() || undefined, + byProductUom: p.byProductUom?.trim() || undefined, + equipmentDescription: equipmentDescription || undefined, + equipmentName: equipmentName || undefined, + equipmentCode: p.equipmentCode?.trim() || undefined, + durationInMinute: p.durationInMinute ?? 0, + prepTimeInMinute: p.prepTimeInMinute ?? 0, + postProdTimeInMinute: p.postProdTimeInMinute ?? 0, + }; + }); +} + +export function buildProcessSaveLine( + row: { + seqNo?: number; + processCode: string; + description: string; + byProduct: string; + byProductUom: string; + equipmentDescription: string; + equipmentName: string; + selectedBagCodes: string[]; + durationInMinute: number; + prepTimeInMinute: number; + postProdTimeInMinute: number; + }, + equipmentList: Array<{ code: string; name: string; description: string }>, + bagItems: BagItemCombo[], +): BomProcessVersionEditLine { + const processCode = row.processCode.trim(); + const description = isPackagingProcess(processCode) + ? joinPackagingDescription(row.selectedBagCodes, bagItems) + : row.description.trim(); + const ed = row.equipmentDescription.trim(); + const en = row.equipmentName.trim(); + const equipmentCode = + ed && en ? resolveEquipmentCode(equipmentList, ed, en) ?? undefined : undefined; + return { + seqNo: row.seqNo, + processCode, + description, + byProduct: row.byProduct.trim() || undefined, + byProductUom: row.byProductUom.trim() || undefined, + equipmentDescription: ed || undefined, + equipmentName: en || undefined, + equipmentCode, + durationInMinute: row.durationInMinute, + prepTimeInMinute: row.prepTimeInMinute, + postProdTimeInMinute: row.postProdTimeInMinute, + }; +} diff --git a/src/components/ImportBom/bomVersionUtils.ts b/src/components/ImportBom/bomVersionUtils.ts new file mode 100644 index 0000000..0768494 --- /dev/null +++ b/src/components/ImportBom/bomVersionUtils.ts @@ -0,0 +1,46 @@ +import type { BomCombo } from "@/app/api/bom"; + +export function bomComboGroupKey(b: BomCombo): string { + const code = (b.code ?? "").trim().toLowerCase(); + const kind = (b.bomKind ?? b.description ?? "FG").trim().toLowerCase(); + if (code) return `${code}|${kind}`; + const labelCode = String(b.label ?? "").split(" - ")[0]?.trim().toLowerCase() ?? ""; + return `${labelCode}|${kind}`; +} + +function pickDefaultFromGroup(group: BomCombo[]): BomCombo | null { + if (group.length === 0) return null; + if (group.length === 1) return group[0]; + + const active = group.filter((b) => b.status === "active"); + if (active.length === 1) return active[0]; + if (active.length > 1) return null; + + return group.reduce((best, b) => + (b.revisionNo ?? 0) > (best.revisionNo ?? 0) ? b : best, + ); +} + +/** Pick a single BOM when search matches multiple rows (e.g. active vs inactive versions). */ +export function pickDefaultBom(matches: BomCombo[]): BomCombo | null { + if (matches.length === 0) return null; + if (matches.length === 1) return matches[0]; + + const groups = new Map(); + for (const b of matches) { + const key = bomComboGroupKey(b); + groups.set(key, [...(groups.get(key) ?? []), b]); + } + + if (groups.size === 1) { + return pickDefaultFromGroup([...groups.values()][0]); + } + + return null; +} + +/** Matches that require the user to pick manually (multiple actives or FG+WIP). */ +export function getAmbiguousBomMatches(matches: BomCombo[]): BomCombo[] { + if (pickDefaultBom(matches)) return []; + return matches; +} diff --git a/src/components/ImportBom/putawayLocationUtils.ts b/src/components/ImportBom/putawayLocationUtils.ts new file mode 100644 index 0000000..5f12c22 --- /dev/null +++ b/src/components/ImportBom/putawayLocationUtils.ts @@ -0,0 +1,52 @@ +export type PutawayLocationParts = { + floor: string; + warehouse: string; + area: string; + slot: string; +}; + +export function parsePutawayLocationCode(code?: string | null): PutawayLocationParts { + const parts = (code ?? "").split("-"); + return { + floor: parts[0] ?? "", + warehouse: parts[1] ?? "", + area: parts[2] ?? "", + slot: parts[3] ?? "", + }; +} + +export function buildPutawayLocationCode(parts: PutawayLocationParts): string { + const floor = parts.floor.trim(); + const warehouse = parts.warehouse.trim(); + const area = parts.area.trim(); + const slot = parts.slot.trim(); + if (!floor && !warehouse && !area && !slot) { + return ""; + } + return `${floor}-${warehouse}-${area}-${slot}`; +} + +export function formatPutawayLocationDisplay(code?: string | null): string { + const built = buildPutawayLocationCode(parsePutawayLocationCode(code)); + return built || "-"; +} + +export function validatePutawayLocationParts( + parts: PutawayLocationParts, + warehouseCodes: ReadonlySet, +): string | null { + const values = [parts.floor, parts.warehouse, parts.area, parts.slot]; + const anyFilled = values.some((v) => v.trim().length > 0); + const allFilled = values.every((v) => v.trim().length > 0); + if (anyFilled && !allFilled) { + return "請填寫完整的樓層、倉庫、區域、位置"; + } + if (!allFilled) { + return null; + } + const code = buildPutawayLocationCode(parts); + if (!warehouseCodes.has(code)) { + return `倉庫不存在:${code}`; + } + return null; +} diff --git a/src/components/JoSearch/JoCreateFormModal.tsx b/src/components/JoSearch/JoCreateFormModal.tsx index 033e348..882d613 100644 --- a/src/components/JoSearch/JoCreateFormModal.tsx +++ b/src/components/JoSearch/JoCreateFormModal.tsx @@ -3,7 +3,7 @@ import { JoDetail } from "@/app/api/jo"; import { SaveJo, manualCreateJo } from "@/app/api/jo/actions"; import { OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT, dateStringToDayjs, dayjsToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; import { Check } from "@mui/icons-material"; -import { Autocomplete, Box, Button, Card, Checkbox, CircularProgress, FormControlLabel, Grid, Modal, Stack, TextField, Typography ,FormControl, InputLabel, Select, MenuItem,InputAdornment} from "@mui/material"; +import { Alert, Autocomplete, Box, Button, Card, Checkbox, CircularProgress, FormControlLabel, Grid, Modal, Stack, TextField, Typography ,FormControl, InputLabel, Select, MenuItem,InputAdornment} from "@mui/material"; import { DatePicker, DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs, { Dayjs } from "dayjs"; @@ -17,6 +17,7 @@ import { JobTypeResponse } from "@/app/api/jo/actions"; interface Props { open: boolean; bomCombo: BomCombo[]; + bomIssueCountByBomId?: Record; jobTypes: JobTypeResponse[]; defaultPlanStart: string; rememberPlanStart: boolean; @@ -28,6 +29,7 @@ interface Props { const JoCreateFormModal: React.FC = ({ open, bomCombo, + bomIssueCountByBomId = {}, jobTypes, defaultPlanStart, rememberPlanStart, @@ -54,6 +56,7 @@ const JoCreateFormModal: React.FC = ({ // 监听 bomId 变化 const selectedBomId = watch("bomId"); + const selectedBomIssueCount = selectedBomId ? (bomIssueCountByBomId[selectedBomId] ?? 0) : 0; /* const handleAutoCompleteChange = useCallback( (event: SyntheticEvent, value: BomCombo, onChange: (...event: any[]) => void) => { @@ -282,6 +285,11 @@ const JoCreateFormModal: React.FC = ({ /> )} /> + {selectedBomIssueCount > 0 ? ( + + {t("joCreate_bom_issue_warning", { count: selectedBomIssueCount })} + + ) : null} = ({ validate: (value) => value > 0 }} render={({ field, fieldState: { error } }) => { - const selectedBom = bomCombo.find(bom => bom.id === formProps.watch("bomId")); + const selectedBom = bomCombo.find(bom => bom.id === selectedBomId); const uom = selectedBom?.outputQtyUom || ""; const outputQty = selectedBom?.outputQty ?? 0; const calculatedValue = multiplier * outputQty; @@ -541,7 +549,7 @@ const JoCreateFormModal: React.FC = ({ variant="contained" startIcon={isSubmitting ? : } type="submit" - disabled={isSubmitting} + disabled={isSubmitting|| selectedBomIssueCount > 0} > {isSubmitting ? t("Creating...") : t("Create")} diff --git a/src/components/JoSearch/JoSearch.tsx b/src/components/JoSearch/JoSearch.tsx index 46638b1..35bee07 100644 --- a/src/components/JoSearch/JoSearch.tsx +++ b/src/components/JoSearch/JoSearch.tsx @@ -36,13 +36,20 @@ import { useJoCreatePlanStartPrefs } from "@/hooks/useJoCreatePlanStartPrefs"; interface Props { defaultInputs: SearchJoResultRequest, bomCombo: BomCombo[] + bomIssueCountByBomId: Record; printerCombo: PrinterCombo[]; jobTypes: JobTypeResponse[]; } type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; -const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobTypes }) => { +const JoSearch: React.FC = ({ + defaultInputs, + bomCombo, + bomIssueCountByBomId, + printerCombo, + jobTypes, +}) => { const { t } = useTranslation("jo"); const router = useRouter() const [filteredJos, setFilteredJos] = useState([]); @@ -767,6 +774,7 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT { const [ bomCombo, + bomComboIssues, printerCombo, jobTypes ] = await Promise.all([ fetchBomCombo(), + fetchBomComboIssues(), fetchPrinterCombo(), fetchAllJobTypes() ]) + + const bomIssueCountByBomId = bomComboIssues.reduce>((acc, issue) => { + const id = issue.bomId; + acc[id] = (acc[id] ?? 0) + 1; + return acc; + }, {}); - return + return ( + + ); } JoSearchWrapper.Loading = GeneralLoading; diff --git a/src/components/JoWorkbench/JoWorkbenchSearch.tsx b/src/components/JoWorkbench/JoWorkbenchSearch.tsx index 0da4da8..97f08d4 100644 --- a/src/components/JoWorkbench/JoWorkbenchSearch.tsx +++ b/src/components/JoWorkbench/JoWorkbenchSearch.tsx @@ -37,13 +37,20 @@ import { useJoCreatePlanStartPrefs } from "@/hooks/useJoCreatePlanStartPrefs"; interface Props { defaultInputs: SearchJoResultRequest, bomCombo: BomCombo[] + bomIssueCountByBomId?: Record; printerCombo: PrinterCombo[]; jobTypes: JobTypeResponse[]; } type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; -const JoWorkbenchSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobTypes = [] }) => { +const JoWorkbenchSearch: React.FC = ({ + defaultInputs, + bomCombo, + bomIssueCountByBomId = {}, + printerCombo, + jobTypes = [], +}) => { const { t } = useTranslation("jo"); const router = useRouter() const [filteredJos, setFilteredJos] = useState([]); @@ -768,6 +775,7 @@ const JoWorkbenchSearch: React.FC = ({ defaultInputs, bomCombo, printerCo void; + onApplied: () => void; + bomIds?: number[]; + bomMaterialIds?: number[]; +} + +interface HeaderEdit { + outputQty: string; + outputQtyStock: string; +} + +interface MaterialEdit { + stockQty: string; + baseQty: string; + recipeQty: string; + baseUomId: number; + recipeUomId: number; + availableRecipeUoms: BomMaterialUomOption[]; +} + +function qtyStr(v: BomUomQtyValue | null | undefined): string { + if (v?.qty == null || v.qty === "") return ""; + return String(v.qty); +} + +function numOrNull(s: string): number | null { + const t = s.trim(); + if (!t) return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; +} + +function uomLabel(u: BomUomLabel | null | undefined): string { + return u?.label?.trim() || u?.code?.trim() || "-"; +} + +const BOM_QTY_WARNING_I18N: Record = { + BOM_RECIPE_QTY_NOT_SET: "masterDataIssue_align_warn_field_recipe_qty", + BOM_RECIPE_UOM_NOT_SET: "masterDataIssue_align_warn_field_recipe_uom", +}; + +function formatBeforeQty( + v: BomUomQtyValue | null | undefined, + notSetLabel: string, +): string { + const qty = qtyStr(v); + const uom = uomLabel(v?.uom); + if (!qty) { + return uom !== "-" ? `${notSetLabel} (${uom})` : notSetLabel; + } + return `${qty} ${uom}`; +} + +function formatHeaderBeforeQty( + qty: number | string | null | undefined, + uom: BomUomLabel | null | undefined, + notSetLabel: string, +): string { + const q = qty != null && qty !== "" ? String(qty) : ""; + const u = uomLabel(uom); + if (!q) { + return u !== "-" ? `${notSetLabel} (${u})` : notSetLabel; + } + return `${q} ${u}`; +} + +const ALIGN_CONTROL_HEIGHT = 40; +const alignQtyInputSx = { + width: 100, + "& .MuiOutlinedInput-root": { + height: ALIGN_CONTROL_HEIGHT, + display: "flex", + alignItems: "center", + }, + "& .MuiOutlinedInput-input": { + boxSizing: "border-box", + height: "100%", // Let the input take up the full height of the container + padding: "0 14px", // Left and right padding only + display: "flex", + alignItems: "center", // Vertically center text if supported, or rely on line-height matching height + lineHeight: `${ALIGN_CONTROL_HEIGHT}px`, // Force line-height to equal the control height for vertical centering + fontSize: "1rem", + }, +}; +const alignUomSelectSx = { + minWidth: 160, + "& .MuiOutlinedInput-root": { + height: ALIGN_CONTROL_HEIGHT, + display: "flex", + alignItems: "center", + }, + "& .MuiSelect-select": { + display: "flex", + alignItems: "center", + height: "auto", + boxSizing: "border-box", + padding: "0 32px 0 14px !important", + lineHeight: 1.4375, + fontSize: "1rem", + }, +}; +const alignUomLabelSx = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", // Ensures content centers nicely + height: ALIGN_CONTROL_HEIGHT, // Change from minHeight to height to perfectly match the input box + color: "success.dark", + fontSize: "1rem", + lineHeight: 1, // Flexbox handle vertical centering; line-height 1 prevents offset shifts +}; + +function initMaterialEdit(row: BomMaterialUomAlignmentPreview): MaterialEdit | null { + const baseUomId = row.afterBaseQty?.uom?.uomId; + const available = row.availableRecipeUoms ?? []; + const recipeUomId = + available.length === 1 + ? available[0].uomId + : row.suggestedRecipeUomId ?? + row.afterRecipeQty?.uom?.uomId ?? + available[0]?.uomId; + if (baseUomId == null || recipeUomId == null) return null; + return { + stockQty: qtyStr(row.afterStockQty), + baseQty: qtyStr(row.afterBaseQty), + recipeQty: qtyStr(row.afterRecipeQty), + baseUomId, + recipeUomId, + availableRecipeUoms: available, + }; +} + +const BomUomAlignmentDialog: React.FC = ({ + open, + onClose, + onApplied, + bomIds, + bomMaterialIds, +}) => { + const { t } = useTranslation("masterDataIssue"); + const [tab, setTab] = useState(0); + const [loading, setLoading] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [preview, setPreview] = useState(null); + const [headerEdits, setHeaderEdits] = useState>({}); + const [materialEdits, setMaterialEdits] = useState>({}); + const recalcTimerRef = useRef | null>(null); + const headerRecalcTimerRef = useRef | null>(null); + const inFlightRef = useRef(false); + const applyInFlightRef = useRef(false); + + const resolveInitialTab = useCallback( + (data: BomUomAlignmentPreviewResponse) => { + const headerCount = data.headers.filter((h) => h.canApply).length; + const materialCount = data.materials.filter((m) => m.canApply).length; + const blockedCount = data.materials.filter((m) => !m.canApply).length; + const skippedTotal = data.skipped.length + blockedCount; + if (headerCount === 0 && materialCount === 0 && skippedTotal > 0) return 2; + if (bomMaterialIds?.length && materialCount > 0) return 1; + if (materialCount > 0 && headerCount === 0) return 1; + if (bomMaterialIds?.length) return 1; + if (bomIds?.length && !bomMaterialIds?.length) return 0; + return 0; + }, + [bomIds, bomMaterialIds], + ); + + const loadPreview = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setLoading(true); + setError(null); + try { + const data = await previewBomUomAlignmentClient({ bomIds, bomMaterialIds }); + setPreview(data); + const hEdits: Record = {}; + const headerRecalcTasks: Array> = []; + for (const h of data.headers) { + if (h.canApply) { + const stockQtyStr = + h.afterOutputQtyStock != null && h.afterOutputQtyStock !== "" + ? String(h.afterOutputQtyStock) + : ""; + hEdits[h.bomId] = { + outputQty: + h.afterOutputQty != null && h.afterOutputQty !== "" + ? String(h.afterOutputQty) + : "", + outputQtyStock: stockQtyStr, + }; + const itemId = h.itemId; + const stockUomId = h.afterStockUom?.uomId; + const stockQty = numOrNull(stockQtyStr); + if (itemId != null && stockUomId != null && stockQty != null) { + headerRecalcTasks.push( + recalculateBomHeaderOutputQtyClient({ itemId, stockQty, stockUomId }) + .then((res) => { + if (res.baseQty != null) { + hEdits[h.bomId] = { + ...hEdits[h.bomId], + outputQty: String(res.baseQty), + }; + } + }) + .catch((err) => console.error(err)), + ); + } + } + } + if (headerRecalcTasks.length > 0) { + await Promise.all(headerRecalcTasks); + } + setHeaderEdits(hEdits); + const mEdits: Record = {}; + for (const m of data.materials) { + if (m.canApply) { + const edit = initMaterialEdit(m); + if (edit) mEdits[m.bomMaterialId] = edit; + } + } + setMaterialEdits(mEdits); + setTab(resolveInitialTab(data)); + } catch (e) { + console.error(e); + setError(t("masterDataIssue_align_loadFailed")); + setPreview(null); + } finally { + setLoading(false); + inFlightRef.current = false; + } + }, [t, bomIds, bomMaterialIds, resolveInitialTab]); + + useEffect(() => { + if (open) { + setConfirmed(false); + setTab(bomMaterialIds?.length ? 1 : 0); + void loadPreview(); + } + }, [open, loadPreview, bomMaterialIds]); + + const scheduleRecalc = useCallback( + (bomMaterialId: number, qty: number, recipeUomId: number) => { + if (recalcTimerRef.current) clearTimeout(recalcTimerRef.current); + recalcTimerRef.current = setTimeout(() => { + const edit = materialEdits[bomMaterialId]; + if (!edit) return; + const row = preview?.materials.find((m) => m.bomMaterialId === bomMaterialId); + if (!row?.itemId) return; + const salesUomId = row.afterSaleQty?.uom?.uomId; + const stockUomId = row.afterStockQty?.uom?.uomId; + if (salesUomId == null || stockUomId == null) return; + void recalculateBomMaterialQtyClient({ + itemId: row.itemId, + anchor: "RECIPE_QTY", + qty, + salesUomId, + stockUomId, + baseUomId: edit.baseUomId, + recipeUomId, + }) + .then((res) => { + setMaterialEdits((prev) => { + const cur = prev[bomMaterialId]; + if (!cur) return prev; + return { + ...prev, + [bomMaterialId]: { + ...cur, + stockQty: res.stockQty != null ? String(res.stockQty) : cur.stockQty, + baseQty: res.baseQty != null ? String(res.baseQty) : cur.baseQty, + }, + }; + }); + }) + .catch((err) => console.error(err)); + }, 400); + }, + [materialEdits, preview?.materials], + ); + + const updateMaterialQty = useCallback( + (bomMaterialId: number, value: string) => { + setMaterialEdits((prev) => { + const cur = prev[bomMaterialId]; + if (!cur) return prev; + const n = numOrNull(value); + if (n != null) scheduleRecalc(bomMaterialId, n, cur.recipeUomId); + return { ...prev, [bomMaterialId]: { ...cur, recipeQty: value } }; + }); + }, + [scheduleRecalc], + ); + + const updateMaterialUom = useCallback( + (bomMaterialId: number, recipeUomId: number) => { + setMaterialEdits((prev) => { + const cur = prev[bomMaterialId]; + if (!cur) return prev; + const n = numOrNull(cur.recipeQty); + if (n != null) scheduleRecalc(bomMaterialId, n, recipeUomId); + return { ...prev, [bomMaterialId]: { ...cur, recipeUomId } }; + }); + }, + [scheduleRecalc], + ); + + const scheduleHeaderRecalc = useCallback( + (bomId: number, stockQty: number, header: BomHeaderUomAlignmentPreview) => { + if (headerRecalcTimerRef.current) clearTimeout(headerRecalcTimerRef.current); + headerRecalcTimerRef.current = setTimeout(() => { + const itemId = header.itemId; + const stockUomId = header.afterStockUom?.uomId; + if (itemId == null || stockUomId == null) return; + void recalculateBomHeaderOutputQtyClient({ + itemId, + stockQty, + stockUomId, + }) + .then((res) => { + setHeaderEdits((prev) => { + const cur = prev[bomId]; + if (!cur) return prev; + return { + ...prev, + [bomId]: { + ...cur, + outputQty: res.baseQty != null ? String(res.baseQty) : cur.outputQty, + }, + }; + }); + }) + .catch((err) => console.error(err)); + }, 400); + }, + [], + ); + + const updateHeaderStockQty = useCallback( + (bomId: number, value: string, header: BomHeaderUomAlignmentPreview) => { + const n = numOrNull(value); + if (n != null) scheduleHeaderRecalc(bomId, n, header); + setHeaderEdits((prev) => ({ + ...prev, + [bomId]: { + outputQty: prev[bomId]?.outputQty ?? "", + outputQtyStock: value, + }, + })); + }, + [scheduleHeaderRecalc], + ); + + const handleApply = async () => { + if (!preview || applying || !confirmed || applyInFlightRef.current) return; + applyInFlightRef.current = true; + setApplying(true); + setError(null); + try { + const headers = preview.headers + .filter((h) => h.canApply && (h.afterBaseUom?.uomId ?? h.afterUom?.uomId) != null) + .map((h) => ({ + bomId: h.bomId, + uomId: h.afterBaseUom?.uomId ?? h.afterUom!.uomId!, + outputQtyStock: numOrNull(headerEdits[h.bomId]?.outputQtyStock ?? ""), + stockUomId: h.afterStockUom?.uomId ?? undefined, + })); + + const materials = preview.materials + .filter((m) => m.canApply && materialEdits[m.bomMaterialId]) + .map((m) => { + const e = materialEdits[m.bomMaterialId]; + return { + bomMaterialId: m.bomMaterialId, + qty: numOrNull(e.recipeQty), + uomId: e.recipeUomId, + }; + }); + + await applyBomUomAlignmentClient({ headers, materials }); + onApplied(); + onClose(); + } catch (e) { + console.error(e); + setError(t("masterDataIssue_align_applyFailed")); + } finally { + setApplying(false); + applyInFlightRef.current = false; + } + }; + + const applicableHeaders = preview?.headers.filter((h) => h.canApply) ?? []; + const applicableMaterials = preview?.materials.filter((m) => m.canApply) ?? []; + const blockedMaterials = preview?.materials.filter((m) => !m.canApply) ?? []; + const skippedCount = (preview?.skipped.length ?? 0) + blockedMaterials.length; + const canApply = + confirmed && (applicableHeaders.length > 0 || applicableMaterials.length > 0) && !loading; + + const renderMaterialWarnings = (m: BomMaterialUomAlignmentPreview) => { + const warnings = m.warnings ?? []; + const alerts: React.ReactNode[] = []; + if (warnings.includes("UOM_CONVERSION_ASSUMED_1_TO_1")) { + alerts.push( + + {t("masterDataIssue_align_warn_1to1")} + , + ); + } + const nullFieldLabels = warnings + .filter((w) => w in BOM_QTY_WARNING_I18N) + .map((w) => t(BOM_QTY_WARNING_I18N[w])); + if (nullFieldLabels.length > 0) { + alerts.push( + + {t("masterDataIssue_align_warn_bom_qty_null", { + fields: nullFieldLabels.join("、"), + })} + , + ); + } + return alerts; + }; + + const renderRecipeRow = ( + m: BomMaterialUomAlignmentPreview, + edit: MaterialEdit, + ) => { + const recipeUomLabel = + edit.availableRecipeUoms.length === 1 + ? edit.availableRecipeUoms[0].label + : edit.availableRecipeUoms.find((u) => u.uomId === edit.recipeUomId)?.label ?? + uomLabel(m.afterRecipeQty?.uom); + return ( + + {t("masterDataIssue_align_recipeQty")} + + {formatBeforeQty(m.beforeRecipeQty, t("masterDataIssue_align_not_set"))} + + + + updateMaterialQty(m.bomMaterialId, e.target.value)} + inputProps={{ step: "any", min: 0 }} + sx={alignQtyInputSx} + /> + {edit.availableRecipeUoms.length <= 1 ? ( + + {recipeUomLabel} + + ) : ( + + + + )} + + + + ); + }; + + const renderReadonlyQtyRow = ( + label: string, + before: BomUomQtyValue | null | undefined, + afterQty: string, + afterUom: BomUomLabel | null | undefined, + ) => ( + + {label} + + {formatBeforeQty(before, t("masterDataIssue_align_not_set"))} + + + + {afterQty} {uomLabel(afterUom)} + + + + ); + + const renderHeaderCard = (h: BomHeaderUomAlignmentPreview) => ( + + + {h.bomCode} · {h.bomName} + + + + + {t("masterDataIssue_align_field")} + {t("masterDataIssue_col_bom_uom")} + {t("masterDataIssue_align_after")} + + + + + + {t("masterDataIssue_align_outputStock")} + + + {h.beforeOutputQtyStock == null && h.beforeStockUom == null + ? "—" + : formatHeaderBeforeQty( + h.beforeOutputQtyStock, + h.beforeStockUom, + t("masterDataIssue_align_not_set"), + )} + + + + updateHeaderStockQty(h.bomId, e.target.value, h)} + inputProps={{ step: "any", min: 0 }} + sx={alignQtyInputSx} + /> + + {uomLabel(h.afterStockUom)} + + + + + + + {t("masterDataIssue_align_outputBase")} + + {t("masterDataIssue_align_reference")} + + + + {formatHeaderBeforeQty( + h.beforeOutputQty, + h.beforeBaseUom ?? h.beforeUom, + t("masterDataIssue_align_not_set"), + )} + + + {formatHeaderBeforeQty( + headerEdits[h.bomId]?.outputQty || h.afterOutputQty, + h.afterBaseUom ?? h.afterUom, + t("masterDataIssue_align_not_set"), + )} + + + +
+
+ ); + + const renderReadonlyAfterQty = (after: BomUomQtyValue | null | undefined) => { + const q = qtyStr(after); + const u = uomLabel(after?.uom); + if (!q && u === "-") return "—"; + if (!q) return u; + return `${q} ${u}`; + }; + + const renderBlockedMaterialCard = (m: BomMaterialUomAlignmentPreview) => ( + + + {m.itemCode} · {m.itemName} + + + {t("masterDataIssue_usedInBom", { + count: 1, + codes: `${m.bomCode ?? ""} · ${m.bomName ?? ""}`, + })} + + {m.skipReason ? ( + + {m.skipReason} + + ) : null} + + + + {t("masterDataIssue_align_field")} + {t("masterDataIssue_col_bom_uom")} + {t("masterDataIssue_align_after")} + + + + + {t("masterDataIssue_align_recipeQty")} + + {formatBeforeQty(m.beforeRecipeQty, t("masterDataIssue_align_not_set"))} + + + {renderReadonlyAfterQty(m.afterRecipeQty)} + + + + {t("masterDataIssue_unit_base")} + + {formatBeforeQty(m.beforeBaseQty, t("masterDataIssue_align_not_set"))} + + + {renderReadonlyAfterQty(m.afterBaseQty)} + + + + {t("masterDataIssue_unit_stock")} + + {formatBeforeQty(m.beforeStockQty, t("masterDataIssue_align_not_set"))} + + + {renderReadonlyAfterQty(m.afterStockQty)} + + + +
+
+ ); + + const renderMaterialCard = (m: BomMaterialUomAlignmentPreview) => { + const edit = materialEdits[m.bomMaterialId]; + if (!edit) return null; + const materialWarnings = renderMaterialWarnings(m); + return ( + + + {m.itemCode} · {m.itemName} + + + {t("masterDataIssue_usedInBom", { + count: 1, + codes: `${m.bomCode ?? ""} · ${m.bomName ?? ""}`, + })} + + {materialWarnings.length > 0 ? ( + + {materialWarnings} + + ) : null} + + + + {t("masterDataIssue_align_field")} + {t("masterDataIssue_col_bom_uom")} + {t("masterDataIssue_align_after")} + + + + {renderRecipeRow(m, edit)} + {renderReadonlyQtyRow( + t("masterDataIssue_unit_base"), + m.beforeBaseQty, + edit.baseQty, + m.afterBaseQty?.uom, + )} + {renderReadonlyQtyRow( + t("masterDataIssue_unit_stock"), + m.beforeStockQty, + edit.stockQty, + m.afterStockQty?.uom, + )} + +
+
+ ); + }; + + return ( + + {t("masterDataIssue_align_title")} + + + {t("masterDataIssue_align_info_m18")} + {t("masterDataIssue_align_info_excel")} + + {error ? {error} : null} + + {loading ? ( + + + + ) : preview ? ( + <> + + {t("masterDataIssue_align_summary", { + headers: applicableHeaders.length, + materials: applicableMaterials.length, + skipped: skippedCount, + })} + + setTab(v)}> + + + {skippedCount > 0 ? ( + + ) : null} + + {tab === 0 && + (applicableHeaders.length > 0 ? ( + applicableHeaders.map(renderHeaderCard) + ) : ( + + {t("masterDataIssue_align_none_headers")} + + ))} + {tab === 1 && + (applicableMaterials.length > 0 ? ( + applicableMaterials.map(renderMaterialCard) + ) : ( + + {t("masterDataIssue_align_none_materials")} + + ))} + {tab === 2 && skippedCount > 0 ? ( + + {blockedMaterials.map(renderBlockedMaterialCard)} + {preview.skipped.map((s, i) => ( + + {[s.bomCode, s.itemCode].filter(Boolean).join(" / ")} — {s.reason} + + ))} + + ) : null} + + ) : null} + + setConfirmed(e.target.checked)} /> + } + label={t("masterDataIssue_align_confirm")} + /> + + + + + + + + ); +}; + +export default BomUomAlignmentDialog; diff --git a/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx b/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx index 5f409cb..f127bab 100644 --- a/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx +++ b/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx @@ -27,6 +27,7 @@ import type { MasterDataIssueCode, } from "@/app/api/masterDataIssues"; import MasterDataIssueDetailDialog from "./MasterDataIssueDetailDialog"; +import BomUomAlignmentDialog from "./BomUomAlignmentDialog"; import { buildDisplayLines, materialBomSubtitle, @@ -42,6 +43,42 @@ import { export type MasterDataIssuesPanelMode = "item" | "bom"; +const BOM_ALIGN_ISSUE_CODES = new Set([ + "BOM_OUTPUT_UOM_MISMATCH_BASE", + "BOM_OUTPUT_UOM_TEXT_DRIFT", + "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE", + "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX", + "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX", + "BOM_MATERIAL_DERIVE_FAILED", +]); + +function groupSupportsBomAlign(group: MasterDataIssueGroup): boolean { + return group.issues.some((i) => BOM_ALIGN_ISSUE_CODES.has(i.issueCode)); +} + +function alignScopeFromGroup(group: MasterDataIssueGroup): { + bomIds?: number[]; + bomMaterialIds?: number[]; +} { + if (group.groupType === "bom_header" && group.bomId != null) { + return { bomIds: [group.bomId] }; + } + const bomMaterialIds = Array.from( + new Set( + group.issues + .map((i) => i.bomMaterialId) + .filter((id): id is number => id != null && id > 0), + ), + ); + return { bomMaterialIds }; +} + +function groupCanOpenAlignDialog(group: MasterDataIssueGroup): boolean { + if (!groupSupportsBomAlign(group)) return false; + const scope = alignScopeFromGroup(group); + return (scope.bomIds?.length ?? 0) > 0 || (scope.bomMaterialIds?.length ?? 0) > 0; +} + interface ItemUnitCellProps { status: ItemUnitStatus; value: string; @@ -165,6 +202,10 @@ const MasterDataIssuesPanel: React.FC = ({ onRefresh, }) => { const { t } = useTranslation("masterDataIssue"); + const [alignScope, setAlignScope] = useState<{ + bomIds?: number[]; + bomMaterialIds?: number[]; + } | null>(null); const inFlightRef = useRef(false); const [scopeFilter, setScopeFilter] = useState("ALL"); const [search, setSearch] = useState(""); @@ -245,33 +286,61 @@ const MasterDataIssuesPanel: React.FC = ({ const rows = group.issues .filter((r) => [ - "BOM_OUTPUT_UOM_MISMATCH_SALES", - "BOM_MATERIAL_SALES_UOM_MISMATCH", - "BOM_MATERIAL_STOCK_UOM_MISMATCH", - "BOM_MATERIAL_BASE_UOM_MISMATCH", + "BOM_OUTPUT_UOM_MISMATCH_BASE", + "BOM_ITEM_CODE_MISMATCH", + "BOM_ITEM_NAME_MISMATCH", + "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE", + "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX", + "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX", + "BOM_MATERIAL_DERIVE_FAILED", ].includes(r.issueCode), ) .map((r) => { - const unitLabel = - r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES" - ? t("masterDataIssue_unit_output", { defaultValue: "产出单位" }) - : r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH" - ? t("masterDataIssue_unit_sales") - : r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH" - ? t("masterDataIssue_unit_stock") - : t("masterDataIssue_unit_base"); + if (r.issueCode === "BOM_ITEM_CODE_MISMATCH") { + const label = t("masterDataIssue_col_linked_item"); + return { + key: `${r.issueCode}-${r.bomId ?? "x"}`, + bomUnitLabel: label, + itemUnitLabel: label, + bomValue: r.actualValue ?? "-", + itemValue: r.expectedValue ?? "-", + }; + } + if (r.issueCode === "BOM_ITEM_NAME_MISMATCH") { + const label = t("masterDataIssue_col_product_name"); + return { + key: `${r.issueCode}-${r.bomId ?? "x"}`, + bomUnitLabel: label, + itemUnitLabel: label, + bomValue: r.actualValue ?? "-", + itemValue: r.expectedValue ?? "-", + }; + } + if (r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_BASE") { + const baseLabel = t("masterDataIssue_unit_base"); + return { + key: `${r.issueCode}-${r.bomId ?? r.bomMaterialId ?? "x"}`, + bomUnitLabel: baseLabel, + itemUnitLabel: baseLabel, + bomValue: r.actualValue ?? "-", + itemValue: r.expectedValue ?? "-", + }; + } return { key: `${r.issueCode}-${r.bomMaterialId ?? "x"}`, - unitLabel, + bomUnitLabel: t("masterDataIssue_unit_recipe", { defaultValue: "配方單位" }), + itemUnitLabel: t("masterDataIssue_unit_base"), bomValue: r.actualValue ?? "-", itemValue: r.expectedValue ?? "-", }; }); if (rows.length > 0) return rows; + const baseLabel = t("masterDataIssue_unit_base"); return [ { key: "fallback", - unitLabel: t("masterDataIssue_unit_output", { defaultValue: "产出单位" }), + bomUnitLabel: baseLabel, + itemUnitLabel: baseLabel, bomValue: "-", itemValue: "-", }, @@ -280,7 +349,8 @@ const MasterDataIssuesPanel: React.FC = ({ interface BomCompareRow { key: string; - unitLabel: string; + bomUnitLabel: string; + itemUnitLabel: string; bomValue: string; itemValue: string; } @@ -305,7 +375,7 @@ const BomCompareColumn: React.FC = ({ variant="body2" sx={{ color: labelColor, fontWeight: 600, lineHeight: 1.3 }} > - {row.unitLabel} + {valueKey === "bomValue" ? row.bomUnitLabel : row.itemUnitLabel} = ({ + {mode === "bom" ? ( + setAlignScope(null)} + onApplied={() => void onRefresh()} + bomIds={alignScope?.bomIds} + bomMaterialIds={alignScope?.bomMaterialIds} + /> + ) : null} + {loadError && ( {loadError} @@ -461,6 +541,9 @@ const BomCompareColumn: React.FC = ({ <> {t("masterDataIssue_col_bom_uom", { defaultValue: "BOM UOM" })} {t("masterDataIssue_col_item_uom", { defaultValue: "Item 正确 UOM" })} + + {t("masterDataIssue_col_actions")} + ) : ( <> @@ -560,6 +643,18 @@ const BomCompareColumn: React.FC = ({ valueColor="success.dark" /> + + {groupCanOpenAlignDialog(group) ? ( + + ) : null} + ) : ( <> diff --git a/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx b/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx index adc6602..63ba853 100644 --- a/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx +++ b/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx @@ -8,10 +8,12 @@ import { fetchBomMasterDataIssuesClient, fetchItemMasterDataIssuesClient, } from "@/app/api/masterDataIssues/client"; +import { useAuthReady } from "@/app/(main)/axios/AxiosProvider"; import MasterDataIssuesPanel from "./MasterDataIssuesPanel"; const MasterDataIssuesTabs: React.FC = () => { const { t } = useTranslation("masterDataIssue"); + const isAuthReady = useAuthReady(); const [tab, setTab] = useState(0); const [bomIssues, setBomIssues] = useState([]); const [itemIssues, setItemIssues] = useState([]); @@ -63,14 +65,14 @@ const MasterDataIssuesTabs: React.FC = () => { }, [t]); React.useEffect(() => { + if (!isAuthReady) return; void loadBomIssues(); - }, [loadBomIssues]); + }, [isAuthReady, loadBomIssues]); React.useEffect(() => { - if (tab === 1 && !itemLoadedRef.current) { - void loadItemIssues(); - } - }, [tab, loadItemIssues]); + if (!isAuthReady || tab !== 1 || itemLoadedRef.current) return; + void loadItemIssues(); + }, [isAuthReady, tab, loadItemIssues]); return ( @@ -87,7 +89,7 @@ const MasterDataIssuesTabs: React.FC = () => { @@ -95,7 +97,7 @@ const MasterDataIssuesTabs: React.FC = () => { diff --git a/src/components/MasterDataIssues/buildDisplayLines.ts b/src/components/MasterDataIssues/buildDisplayLines.ts index 40b30d0..4c35107 100644 --- a/src/components/MasterDataIssues/buildDisplayLines.ts +++ b/src/components/MasterDataIssues/buildDisplayLines.ts @@ -55,69 +55,40 @@ function groupIssuesByBom(issues: MasterDataIssue[]): Map string, ): string[] { const lines: string[] = []; - const sales = rows.find((r) => r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH"); - const stock = rows.find((r) => r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH"); - const base = rows.find((r) => r.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH"); + const used = new Set(); - if ( - sales && - stock && - sales.expectedValue === stock.expectedValue && - sales.actualValue === stock.actualValue - ) { + const itemCodeMismatch = rows.find((r) => r.issueCode === "BOM_ITEM_CODE_MISMATCH"); + if (itemCodeMismatch) { lines.push( - t("masterDataIssue_line_pairBoth", { - expected: sales.expectedValue ?? "-", - actual: sales.actualValue ?? "-", + t("masterDataIssue_line_itemCodeMismatch", { + bom: bomCode, + expected: itemCodeMismatch.expectedValue ?? "-", + actual: itemCodeMismatch.actualValue ?? "-", }), ); - } else { - if (sales) { - lines.push( - t("masterDataIssue_line_pairSales", { - expected: sales.expectedValue ?? "-", - actual: sales.actualValue ?? "-", - }), - ); - } - if (stock) { - lines.push( - t("masterDataIssue_line_pairStock", { - expected: stock.expectedValue ?? "-", - actual: stock.actualValue ?? "-", - }), - ); - } + used.add(itemCodeMismatch); } - if (base) { + const itemNameMismatch = rows.find((r) => r.issueCode === "BOM_ITEM_NAME_MISMATCH"); + if (itemNameMismatch) { lines.push( - t("masterDataIssue_line_pairBase", { - expected: base.expectedValue ?? "-", - actual: base.actualValue ?? "-", + t("masterDataIssue_line_itemNameMismatch", { + bom: bomCode, + expected: itemNameMismatch.expectedValue ?? "-", + actual: itemNameMismatch.actualValue ?? "-", }), ); + used.add(itemNameMismatch); } - return lines; -} - -function linesForBomHeaderRows( - bomCode: string, - rows: MasterDataIssue[], - t: TFunction, - issueMessage: (code: string) => string, -): string[] { - const lines: string[] = []; - const used = new Set(); - - const output = rows.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES"); + const output = rows.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_BASE"); if (output) { lines.push( t("masterDataIssue_line_outputUom", { @@ -211,21 +182,21 @@ export function buildDisplayLines( return linesForBomHeaderRows(bom, group.issues, t, issueMessage); } - // BOM material: one row per (material + 应为/实际); right side = pair only, BOM list on left - const lines = uomPairLines(group.issues, t); - const used = new Set( - group.issues.filter((r) => - [ - "BOM_MATERIAL_SALES_UOM_MISMATCH", - "BOM_MATERIAL_STOCK_UOM_MISMATCH", - "BOM_MATERIAL_BASE_UOM_MISMATCH", - ].includes(r.issueCode), - ), - ); - + // BOM material: recipe-qty model issues + const lines: string[] = []; for (const row of group.issues) { - if (used.has(row)) continue; - lines.push(issueMessage(row.issueCode)); + const short = issueMessage(row.issueCode); + if (row.expectedValue != null || row.actualValue != null) { + lines.push( + t("masterDataIssue_line_itemGeneric", { + problem: short, + expected: row.expectedValue ?? "-", + actual: row.actualValue ?? "-", + }), + ); + } else { + lines.push(short); + } } return lines.length > 0 ? lines : [issueMessage(group.issues[0]?.issueCode ?? "")]; @@ -267,60 +238,7 @@ export function buildDetailRows( for (const [bomCode, bomIssues] of Array.from(byBom.entries())) { const bomLabel = bomLabelForCode(group.issues, bomCode); const used = new Set(); - const sales = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH"); - const stock = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH"); - const base = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH"); - - if ( - sales && - stock && - sales.expectedValue === stock.expectedValue && - sales.actualValue === stock.actualValue - ) { - rows.push({ - key: `${bomCode}-uom-both`, - bomLabel, - problem: t("masterDataIssue_detail_uomBoth"), - expected: sales.expectedValue, - actual: sales.actualValue, - }); - used.add(sales); - used.add(stock); - } else { - if (sales) { - rows.push({ - key: `${bomCode}-sales`, - bomLabel, - problem: t("masterDataIssue_detail_uomSales"), - expected: sales.expectedValue, - actual: sales.actualValue, - }); - used.add(sales); - } - if (stock) { - rows.push({ - key: `${bomCode}-stock`, - bomLabel, - problem: t("masterDataIssue_detail_uomStock"), - expected: stock.expectedValue, - actual: stock.actualValue, - }); - used.add(stock); - } - } - - if (base) { - rows.push({ - key: `${bomCode}-base`, - bomLabel, - problem: t("masterDataIssue_detail_uomBase"), - expected: base.expectedValue, - actual: base.actualValue, - }); - used.add(base); - } - - const output = bomIssues.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES"); + const output = bomIssues.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_BASE"); if (output) { rows.push({ key: `${bomCode}-output`, diff --git a/src/components/MasterDataIssues/groupMasterDataIssues.ts b/src/components/MasterDataIssues/groupMasterDataIssues.ts index fe182d6..2ce4aec 100644 --- a/src/components/MasterDataIssues/groupMasterDataIssues.ts +++ b/src/components/MasterDataIssues/groupMasterDataIssues.ts @@ -110,18 +110,15 @@ export function bomLabelForCode( return formatBomLabel({ bomCode: code, bomName: names.get(code) }); } -/** Group BOM material rows by item + same 应为/实际 (sales+stock with same pair share one key). */ +/** Group BOM material rows by item + issue pattern. */ function materialPatternKey(row: MasterDataIssue): string { const exp = row.expectedValue ?? ""; const act = row.actualValue ?? ""; if ( - row.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH" || - row.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH" + row.issueCode === "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" || + row.issueCode === "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" ) { - return `uom:${exp}|${act}`; - } - if (row.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH") { - return `base:${exp}|${act}`; + return `recipe:${exp}|${act}`; } return `${row.issueCode}|${exp}|${act}`; } diff --git a/src/components/ProductionProcess/JobOrderBomAttributesGrid.tsx b/src/components/ProductionProcess/JobOrderBomAttributesGrid.tsx new file mode 100644 index 0000000..20a2024 --- /dev/null +++ b/src/components/ProductionProcess/JobOrderBomAttributesGrid.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Box, Grid, Paper, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { + BomAttributeSummaryInput, + buildBomAttributeGridItems, +} from "../ImportBom/bomAttributeScales"; + +type Props = { + attributes: BomAttributeSummaryInput; + sectionTitle?: string; +}; + +export default function JobOrderBomAttributesGrid({ attributes, sectionTitle }: Props) { + const { t: tBom } = useTranslation("importBom"); + const { t } = useTranslation("productionProcess"); + const items = buildBomAttributeGridItems(attributes); + const title = sectionTitle ?? t("Bom Product Attributes"); + + return ( + + + {title} + + + {items.map((item) => ( + + + + {tBom(item.labelKey)} + + + {item.value} + + + + ))} + + + ); +} diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 4859629..f503b6c 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -42,7 +42,7 @@ import ProcessSummaryHeader from "./ProcessSummaryHeader"; import EditIcon from "@mui/icons-material/Edit"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import { dayjsToDateString } from "@/app/utils/formatUtil"; +import JobOrderBomAttributesGrid from "./JobOrderBomAttributesGrid"; @@ -222,7 +222,7 @@ const handleConfirmPlanStart = useCallback(async () => { if (!jobOrderId || !planStartDate) return; // 将日期转换为后端需要的格式 (YYYY-MM-DDTHH:mm:ss) - const dateString = `${dayjsToDateString(planStartDate, "input")}T00:00:00`; + const dateString = `${arrayToDateString(planStartDate, "input")}T00:00:00`; await handleUpdatePlanStart(jobOrderId, dateString); setOpenPlanStartDialog(false); setPlanStartDate(null); @@ -437,13 +437,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { }} />
- - + + diff --git a/src/components/ScheduleTable/BomMaterialTable.tsx b/src/components/ScheduleTable/BomMaterialTable.tsx index 89e0825..8da3437 100644 --- a/src/components/ScheduleTable/BomMaterialTable.tsx +++ b/src/components/ScheduleTable/BomMaterialTable.tsx @@ -166,7 +166,7 @@ function BomMaterialTable({ bomMaterial }: Props) { newRow: GridRowModel, ): BomMaterialEntryError | undefined => { const error: BomMaterialEntryError = {}; - console.log(newRow); + //console.log(newRow); return Object.keys(error).length > 0 ? error : undefined; }, [], diff --git a/src/components/Shop/Shop.tsx b/src/components/Shop/Shop.tsx index a78aac8..01d588f 100644 --- a/src/components/Shop/Shop.tsx +++ b/src/components/Shop/Shop.tsx @@ -166,7 +166,7 @@ const Shop: React.FC = () => { setError(null); try { const data = await fetchAllShopsClient(params) as ShopAndTruck[]; - console.log("Fetched shops data:", data); + //console.log("Fetched shops data:", data); // Group data by shop ID (one shop can have multiple TruckLanceCode entries) const shopMap = new Map(); diff --git a/src/hooks/useMasterDataIssueNavCount.ts b/src/hooks/useMasterDataIssueNavCount.ts index f9fe303..a10d038 100644 --- a/src/hooks/useMasterDataIssueNavCount.ts +++ b/src/hooks/useMasterDataIssueNavCount.ts @@ -1,11 +1,13 @@ "use client"; +import { useAuthReady } from "@/app/(main)/axios/AxiosProvider"; import { fetchMasterDataIssuesSummaryClient } from "@/app/api/masterDataIssues/client"; import { useCallback, useEffect, useRef, useState } from "react"; const POLL_MS = 120_000; export function useMasterDataIssueNavCount(enabled: boolean) { + const isAuthReady = useAuthReady(); const [bomGroupCount, setBomGroupCount] = useState(0); const [itemGroupCount, setItemGroupCount] = useState(0); const [totalGroupCount, setTotalGroupCount] = useState(0); @@ -13,7 +15,7 @@ export function useMasterDataIssueNavCount(enabled: boolean) { const inFlightRef = useRef(false); const load = useCallback(async () => { - if (!enabled) { + if (!enabled || !isAuthReady) { setBomGroupCount(0); setItemGroupCount(0); setTotalGroupCount(0); @@ -38,10 +40,10 @@ export function useMasterDataIssueNavCount(enabled: boolean) { setLoading(false); inFlightRef.current = false; } - }, [enabled]); + }, [enabled, isAuthReady]); useEffect(() => { - if (!enabled) { + if (!enabled || !isAuthReady) { setBomGroupCount(0); setItemGroupCount(0); setTotalGroupCount(0); @@ -50,7 +52,7 @@ export function useMasterDataIssueNavCount(enabled: boolean) { void load(); const id = window.setInterval(() => void load(), POLL_MS); return () => window.clearInterval(id); - }, [enabled, load]); + }, [enabled, isAuthReady, load]); return { bomGroupCount, itemGroupCount, totalGroupCount, loading, reload: load }; } diff --git a/src/i18n/en/importBom.json b/src/i18n/en/importBom.json index 17fcd27..47fe70c 100644 --- a/src/i18n/en/importBom.json +++ b/src/i18n/en/importBom.json @@ -101,10 +101,15 @@ "Allergic Substances": "Allergic Substances", "Depth": "Color depth", "Depth hint": "Dark 1 → Light 5", + "Depth label": "Color depth Dark 1 Light 5", "Float": "Float", + "Float label": "Float/Sink Float 3 Sink 5", "Density": "Density", + "Density label": "Density Thick 3 Thin 5", "Time Sequence": "Time Sequence", + "Time Sequence label": "Time period PM 1 AM 5", "Complexity": "Complexity", + "Complexity label": "Complexity Simple 10 Medium 5 Complex 3", "Scrap Rate": "Scrap Rate", "Item Code": "Item Code", "Item Name": "Item Name", @@ -144,9 +149,13 @@ "Load process master failed": "Failed to load process/equipment master data", "Process code required": "Process code is required on every line", "Process not in master": "Process {{code}} is not in master data", + "Import process not in master": "Excel process \"{{name}}\" is not in master data — please select a valid process", "Equipment description and name both required": "Equipment description and name must both be set or both empty", "Equipment not in master": "Equipment \"{{pair}}\" is not in master data", "Bag item not in master": "Bag item {{code}} is not in master data", "Byproduct item not in master": "Byproduct item {{code}} does not exist", - "Item code": "Item code" + "Item code": "Item code", + "bomSave_block_unresolvedMaterialIssue": "Please fix unresolved material UOM issue(s) first ({{count}} line(s))." + ,"bomHeaderOutputQtyStockConvertFail_warn": "BOM output quantity (stock UOM) cannot be converted. Please fix BOM unit alignment first." + ,"bomPutawayLocation_missing_warn": "FG BOM putaway location is missing. Please set a complete putaway location before saving." } diff --git a/src/i18n/en/jo.json b/src/i18n/en/jo.json index 56939b2..1e2ce1f 100644 --- a/src/i18n/en/jo.json +++ b/src/i18n/en/jo.json @@ -168,7 +168,7 @@ "Invalid date format": "Invalid date format", "Inventory": "Inventory", "Is Dark": "Is Dark", - "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity", + "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Is Dark | Float | Dense | Scrap Rate | Allergic Substance | Time Sequence | Complexity", "Is Dense": "Is Dense", "Is Float": "Is Float", "Issue": "Issue", @@ -649,5 +649,6 @@ "value must be a number": "value must be a number", "view putaway": "view putaway", "view stockin": "view stockin", - "warehouse": "warehouse" + "warehouse": "warehouse", + "joCreate_bom_issue_warning": "This BOM has {{count}} master data issue(s). Please fix in BOM/Item Unit Issues first." } diff --git a/src/i18n/en/masterDataIssue.json b/src/i18n/en/masterDataIssue.json index e1bfdc8..4b0a6f8 100644 --- a/src/i18n/en/masterDataIssue.json +++ b/src/i18n/en/masterDataIssue.json @@ -1,11 +1,17 @@ { "Current Stock": "Current Stock", "masterDataIssue": "masterDataIssue", - "masterDataIssue_BOM_MATERIAL_BASE_UOM_MISMATCH": "masterDataIssue_BOM_MATERIAL_BASE_UOM_MISMATCH", - "masterDataIssue_BOM_MATERIAL_MISSING_ITEM": "masterDataIssue_BOM_MATERIAL_MISSING_ITEM", + "masterDataIssue_BOM_MATERIAL_MISSING_ITEM": "BOM material item missing or deleted", + "masterDataIssue_BOM_MATERIAL_MISSING_QTY": "BOM material recipe qty not set", + "masterDataIssue_BOM_MATERIAL_MISSING_RECIPE_UOM": "BOM material recipe UOM not set", "masterDataIssue_BOM_MATERIAL_SALES_UOM_MISMATCH": "masterDataIssue_BOM_MATERIAL_SALES_UOM_MISMATCH", "masterDataIssue_BOM_MATERIAL_STOCK_UOM_MISMATCH": "masterDataIssue_BOM_MATERIAL_STOCK_UOM_MISMATCH", + "masterDataIssue_BOM_MATERIAL_BASE_UOM_MISMATCH": "masterDataIssue_BOM_MATERIAL_BASE_UOM_MISMATCH", "masterDataIssue_BOM_MATERIAL_UOM_FK_INVALID": "BOM material UOM reference invalid or deleted", + "masterDataIssue_BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX": "Item base UOM is not in the standard matrix; recipe cannot be converted", + "masterDataIssue_BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE": "BOM material recipe UOM cannot convert to item base UOM", + "masterDataIssue_BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX": "BOM material recipe UOM is not in the standard matrix and cannot be derived", + "masterDataIssue_BOM_MATERIAL_DERIVE_FAILED": "BOM material quantities cannot be derived from recipe qty and UOM", "masterDataIssue_BOM_ITEM_CODE_MISMATCH": "BOM code does not match linked item", "masterDataIssue_BOM_ITEM_NAME_MISMATCH": "BOM product name does not match linked item", "masterDataIssue_col_linked_item": "Linked item", @@ -13,6 +19,8 @@ "masterDataIssue_line_itemCodeMismatch": "{{bom}}: should link item \"{{expected}}\", actually \"{{actual}}\"", "masterDataIssue_line_itemNameMismatch": "{{bom}}: BOM/Excel name \"{{actual}}\", item master \"{{expected}}\"", "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_SALES": "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_SALES", + "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_STOCK": "BOM output UOM does not match item stock unit", + "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_BASE": "BOM output UOM cannot convert to M18 base unit", "masterDataIssue_BOM_OUTPUT_UOM_TEXT_DRIFT": "masterDataIssue_BOM_OUTPUT_UOM_TEXT_DRIFT", "masterDataIssue_DELETED_BASE_UOM": "masterDataIssue_DELETED_BASE_UOM", "masterDataIssue_DELETED_PURCHASE_UOM": "masterDataIssue_DELETED_PURCHASE_UOM", @@ -46,7 +54,7 @@ "masterDataIssue_col_expected": "masterDataIssue_col_expected", "masterDataIssue_col_issue": "masterDataIssue_col_issue", "masterDataIssue_col_item": "masterDataIssue_col_item", - "masterDataIssue_col_item_uom": "M18 UOM", + "masterDataIssue_col_item_uom": "M18 unit", "masterDataIssue_col_problem": "masterDataIssue_col_problem", "masterDataIssue_col_scope": "masterDataIssue_col_scope", "masterDataIssue_col_subject": "masterDataIssue_col_subject", @@ -98,16 +106,17 @@ "masterDataIssue_unit_inactive": "masterDataIssue_unit_inactive", "masterDataIssue_unit_missing": "masterDataIssue_unit_missing", "masterDataIssue_unit_output": "masterDataIssue_unit_output", + "masterDataIssue_unit_recipe": "Recipe UOM", "masterDataIssue_unit_picking": "masterDataIssue_unit_picking", "masterDataIssue_unit_purchase": "masterDataIssue_unit_purchase", "masterDataIssue_unit_sales": "masterDataIssue_unit_sales", "masterDataIssue_unit_stock": "masterDataIssue_unit_stock", "masterDataIssue_usedInBom": "masterDataIssue_usedInBom", "masterDataIssue_viewDetail": "masterDataIssue_viewDetail", - "masterDataIssue_align_preview": "Preview fix (align to M18)", - "masterDataIssue_align_row": "Align to M18", - "masterDataIssue_align_title": "Preview: align BOM UOM to M18", - "masterDataIssue_align_info_m18": "If M18 item master is correct: use this to align BOM units and quantities. Header output qty defaults unchanged; material sale/stock/base qty recalculate via item_uom when edited. Recipe qty is independent in v1.", + "masterDataIssue_align_preview": "Preview fix (conversion)", + "masterDataIssue_align_row": "Fix conversion", + "masterDataIssue_align_title": "Preview: fix BOM UOM conversion", + "masterDataIssue_align_info_m18": "If M18 item master is correct: use this to fix BOM rows that cannot convert to M18 units. Header output is stored in base unit with stock unit shown for reference; materials are driven by recipe qty + recipe UOM with derived base/stock qty.", "masterDataIssue_align_info_excel": "If BOM Excel is correct: do not apply this fix; update item UOM in M18 sync instead.", "masterDataIssue_align_summary": "Fixable: {{headers}} header(s) · {{materials}} material(s) · {{skipped}} skipped", "masterDataIssue_align_tab_headers": "BOM header ({{count}})", @@ -118,6 +127,13 @@ "masterDataIssue_align_field": "Field", "masterDataIssue_align_after": "After fix", "masterDataIssue_align_outputQty": "Output qty", + "masterDataIssue_align_outputQtyBase": "Output qty (base)", + "masterDataIssue_align_outputQtyStock": "Output qty (stock)", + "masterDataIssue_align_outputBase": "Output (base)", + "masterDataIssue_align_outputStock": "Output (stock)", + "masterDataIssue_align_outputUomBase": "Output UOM (base)", + "masterDataIssue_align_outputUomStock": "Output UOM (stock)", + "masterDataIssue_align_reference": "Reference only", "masterDataIssue_align_recipeQty": "Recipe qty", "masterDataIssue_align_confirm": "I confirm M18 item master is the source of truth", "masterDataIssue_align_apply": "Apply fix", @@ -127,7 +143,6 @@ "masterDataIssue_align_not_set": "Not set", "masterDataIssue_align_warn_1to1": "BOM sales unit cannot be converted via M18 (likely a legacy Excel import). Sale qty was prefilled 1:1 — please verify before applying.", "masterDataIssue_align_warn_bom_qty_null": "BOM qty not set on the left ({{fields}}). Adopt the M18 values on the right, or fix Excel and re-import.", - "masterDataIssue_align_warn_field_sale": "sale qty", - "masterDataIssue_align_warn_field_stock": "stock qty", - "masterDataIssue_align_warn_field_base": "base qty" + "masterDataIssue_align_warn_field_recipe_qty": "recipe qty", + "masterDataIssue_align_warn_field_recipe_uom": "recipe UOM" } diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index 7e71ded..b69129c 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -17,6 +17,7 @@ "Base UOM": "Base UOM", "Batch Count": "Batch Count", "BoM Material": "BoM Material", + "Bom Product Attributes": "Product attributes", "Bom Req. Qty": "Bom Req. Qty", "Bom Uom": "Bom Uom", "By-product": "By-product", @@ -66,7 +67,7 @@ "stopped": "Stopped", "Invalid Job Order Id": "Invalid Job Order Id", "Invalid Stock In Line Id": "Invalid Stock In Line Id", - "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity", + "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Is Dark | Float | Dense | Scrap Rate | Allergic Substance | Time Sequence | Complexity", "Item": "Item", "Item Code": "Item Code", "Item Name": "Item Name", diff --git a/src/i18n/zh/importBom.json b/src/i18n/zh/importBom.json index c873dfb..73dec05 100644 --- a/src/i18n/zh/importBom.json +++ b/src/i18n/zh/importBom.json @@ -1,5 +1,5 @@ { - "Code": "物品編號", + "Code": "貨品編號", "Name": "物品名稱", "Create Material": "新增材料", "Update Equipment Maintenance and Repair": "更新設備的維護和保養", @@ -10,6 +10,54 @@ "Is Drink": "飲料", "Drink": "飲料", "Powder_Mixture": "箱料粉", + "Other": "其他", + "Product Type": "產品類型", + "Material count": "材料數", + "Process count": "工序數", + "Preview load failed": "預覽資料載入失敗", + "Review import data": "檢視匯入資料", + "Attribute scales": "份量尺度", + "No materials parsed": "未解析到材料", + "No processes parsed": "未解析到工序", + "Import code required": "貨品編號不可為空", + "Import name required": "物品名稱不可為空", + "Import output qty required": "產出數量必須大於 0", + "Material item code required": "材料貨品編號不可為空", + "Material uom required": "材料配方單位不可為空", + "Material qty required": "材料配方數量必須大於 0", + "Material item not in master": "材料貨品 {{code}} 不存在", + "Join step not in process list": "加入步驟必須對應現有工序次序", + "Allergic has": "有", + "Allergic none": "沒有", + "No scale data": "無份量尺度資料", + "Back to reselect files": "返回重選檔案", + "Upload check summary": "已上傳 {{uploaded}} 個檔案,檢查結果共 {{total}} 筆:正確 {{correct}} 個、失敗 {{failed}} 個", + "Upload count mismatch hint": "上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。", + "Search file name": "搜索檔名", + "Search code or name": "搜索編號或名稱", + "Format check issues": "格式檢查問題", + "Review and fix": "檢視並修正", + "Issue count": "{{count}} 個問題", + "Revalidate failed": "重新檢查失敗", + "Download corrected excel": "下載修正版 Excel", + "Downloading...": "下載中…", + "Download corrected excel failed": "下載修正版 Excel 失敗", + "Confirm import": "確認匯入", + "Download issue log": "下載檢查結果 Excel", + "Import completed": "匯入完成", + "Import failed": "匯入失敗,請查看主控台。", + "None": "無", + "Import summary prefix": "將匯入 {{count}} 個 BOM", + "Import summary wip": ",其中 {{wip}} 個同時建立半成品", + "Import summary drink": ",{{drink}} 個飲料", + "Import summary powder": ",{{powder}} 個箱料粉", + "Import summary other": ",{{other}} 個其他", + "WIP": "半成品", + "Bom Kind Mode": "種類", + "Bom Kind FG": "FG", + "Bom Kind WIP": "WIP", + "Bom Kind Both": "both", + "File Name": "檔案名稱", "Base Score": "基礎得分", "Import BOM": "匯入BOM", @@ -17,8 +65,31 @@ "BOM Status Active": "啟用", "BOM Status Inactive": "停用", "Save Status": "儲存狀態", +"BOM Revision": "版本", +"Version Compare": "版本比較", +"Version": "版本", +"Compare Version": "比較版本", +"End Compare": "結束比較", +"Old Version": "舊版本", +"New Version": "新版本", +"Loading versions...": "正在載入版本…", +"Loading compare...": "正在載入比較…", +"Select two different versions to compare": "請選擇兩個不同版本進行比較", +"Field": "欄位", +"Material Changes": "物料差異", +"Old Recipe Qty": "舊配方數量", +"New Recipe Qty": "新配方數量", +"Old Base Qty": "舊基本數量", +"New Base Qty": "新基本數量", +"Recipe Qty": "配方數量", +"Recipe UOM": "配方單位", +"Edit Materials": "編輯物料", +"Save New Version": "儲存為新版本", +"Material edit creates new version hint": "修改物料數量/單位會建立新版本並自動啟用;僅變更狀態不會產生新版本。", +"Content": "內容", "Basic Info": "基本資訊", + "Edit Basic Info": "編輯基本資訊", "Edit": "編輯", "Save": "儲存", "Cancel": "取消", @@ -28,13 +99,19 @@ "Output Quantity UOM": "產出單位", "Type": "類型", "Allergic Substances": "過敏原", - "Depth": "色深", + "Depth": "顔色深淺度", + "Depth hint": "深 1 → 淺 5(色深與深淺相同)", + "Depth label": "顔色深淺度 深1淺5", "Float": "浮沉", + "Float label": "浮沉 浮3沉5", "Density": "濃淡", + "Density label": "濃淡 濃3淡5", "Time Sequence": "時段", + "Time Sequence label": "時段 下午1上午5", "Complexity": "複雜度", + "Complexity label": "複雜度 簡單10 中度5 複雜3", "Scrap Rate": "損耗率", - "Item Code": "物品編號", + "Item Code": "貨品編號", "Item Name": "物品名稱", "Base Qty": "基本數量", "Base UOM": "基本單位", @@ -42,15 +119,44 @@ "Stock UOM": "庫存單位", "Sales Qty": "銷售數量", "Sales UOM": "銷售單位", - "Process & Equipment": "製程與設備", + "Join Step": "加入步驟", + "Putaway Location": "上架位置", + "Putaway Floor": "樓層", + "Putaway Warehouse": "倉庫", + "Putaway Area": "區域", + "Putaway Slot": "位置", + "Item Location Code": "貨品主檔位置", + "Putaway location item hint": "QC 自動上架仍使用貨品主檔 LocationCode;此處為 BOM 版本記錄。", + "Putaway edit creates new version hint": "修改上架位置會建立新版本並同步更新貨品主檔位置。", + "Process & Equipment": "工序與設備", "Process Code": "工序代碼", "Process Description": "工序說明", "Process Name": "工序名稱", - "Duration (Minutes)": "時間(分)", + "Duration (Minutes)": "時間(分鐘)", "Prep Time (Minutes)": "準備時間(分鐘)", - "Post Prod Time (Minutes)": "收尾時間(分鐘)", + "Post Prod Time (Minutes)": "生產後轉換時間(分鐘)", "Add": "新增", "Sequence": "次序", - "Actions": "操作" + "Actions": "操作", + "Edit Processes": "編輯工序", + "Byproduct": "副產品", + "Equipment": "使用設備", + "Equipment Description": "設備種類", + "Equipment Name": "設備名稱", + "Not applicable": "不適用", + "Packaging bags": "包裝袋子", + "Select process": "請選擇工序", + "Load process master failed": "載入工序/設備主檔失敗", + "Process code required": "每行工序代碼不可為空", + "Process not in master": "工序 {{code}} 不在工序主檔", + "Import process not in master": "Excel 工序「{{name}}」不在工序主檔,請選擇正確工序", + "Equipment description and name both required": "設備種類與名稱需同時選取或同時留空", + "Equipment not in master": "設備「{{pair}}」不在設備主檔", + "Bag item not in master": "袋子品項 {{code}} 不在主檔", + "Byproduct item not in master": "副產品貨品 {{code}} 不存在", + "Item code": "貨品編號", + "bomSave_block_unresolvedMaterialIssue": "仍有 {{count}} 筆材料單位問題未修正,請先修正後再儲存。", + "bomHeaderOutputQtyStockConvertFail_warn": "BOM 產出數量(庫存單位)無法換算,請先修正 BOM 單位對齊。", + "bomPutawayLocation_missing_warn": "FG BOM 上架位置未設定,請先填寫完整上架位置後再儲存。" } diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index b76742d..1694037 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -168,7 +168,7 @@ "Invalid date format": "日期格式無效", "Inventory": "庫存", "Is Dark": " 顔色深淺度", - "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 濃淡 | 浮沉 | 損耗率 | 過敏原 | 時間順序 | 複雜度", + "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 浮沉 | 濃淡 | 損耗率 | 過敏原 | 時間順序 | 複雜度", "Is Dense": "濃淡", "Is Float": "浮沉", "Issue": "問題", @@ -177,9 +177,15 @@ "Update Required Quantity": "更新需求數量", "Item": "成品/半成品", "Item Code": "貨品編號", + "Density label": "濃淡 濃3淡5", + "Float label": "浮沉 浮3沉5", + "Time Sequence label": "時段 下午1上午5", + "Complexity label": "複雜度 簡單10 中度5 複雜3", + "Scrap Rate": "損耗率", "Item Name": "物品名稱", "Item already exists in created items": "物品已存在於創建的物品中", "Items": "物品", + "Bom Product Attributes": "BOM 產品屬性", "Jo Pick Order Detail": "工單提料詳情", "Job Order": "工單", "Job Order Code": "工單編號", @@ -187,6 +193,8 @@ "Job Order Item Name": "工單產品名稱", "Job Order Match": "工單對料", "Job Order No.": "工單編號", + "Depth label": "顔色深淺度 深1淺5", + "Job Order Pick Execution": "工單提料", "Job Order Pick Order Details": "工單提料單詳情", "Job Order Pickexcution": "工單提料", @@ -305,7 +313,7 @@ "Plastic box carton qty report this month": "膠茜數目使用數量(本月)", "Plastic box carton qty report this year": "膠茜數目使用數量(本年)", "Plastic box carton qty usage": "膠茜數目", - "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有QR碼,可能是剛剛入庫或轉移入庫或轉移出庫。", + "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有可用QR碼。", "Please enter at least code or name": "請輸入至少編號或名稱", "Please enter quantity for all selected items": "請輸入所有已選擇的物品的數量", "Please finish QR code scan and pick order.": "請完成QR碼掃描和提料單。", @@ -652,5 +660,6 @@ "value must be a number": "值必須是數字", "view putaway": "查看上架詳情", "view stockin": "品檢", - "warehouse": "倉庫" + "warehouse": "倉庫", + "joCreate_bom_issue_warning": "此 BOM 有 {{count}} 項主數據問題,請先到「BOM/貨品單位問題」修正。" } diff --git a/src/i18n/zh/masterDataIssue.json b/src/i18n/zh/masterDataIssue.json index 349417c..94c2402 100644 --- a/src/i18n/zh/masterDataIssue.json +++ b/src/i18n/zh/masterDataIssue.json @@ -47,12 +47,20 @@ "masterDataIssue_MULTIPLE_PICKING_UOM": "多筆揀貨單位設定", "masterDataIssue_MULTIPLE_PURCHASE_UOM": "多筆採購單位設定", "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_SALES": "BOM 產出單位與成品銷售單位不一致", + "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_STOCK": "BOM 產出單位與成品庫存單位不一致", + "masterDataIssue_BOM_OUTPUT_UOM_MISMATCH_BASE": "BOM 產出單位無法換算至 M18 基本單位", "masterDataIssue_BOM_OUTPUT_UOM_TEXT_DRIFT": "BOM 產出單位文字與 UOM 主檔不一致", "masterDataIssue_BOM_MATERIAL_MISSING_ITEM": "BOM 原料貨品不存在或已刪除", + "masterDataIssue_BOM_MATERIAL_MISSING_QTY": "BOM 原料用量未設定", + "masterDataIssue_BOM_MATERIAL_MISSING_RECIPE_UOM": "BOM 原料配方單位未設定", "masterDataIssue_BOM_MATERIAL_SALES_UOM_MISMATCH": "BOM 原料銷售單位與貨品主檔不一致", "masterDataIssue_BOM_MATERIAL_BASE_UOM_MISMATCH": "BOM 原料基本單位與貨品主檔不一致", "masterDataIssue_BOM_MATERIAL_STOCK_UOM_MISMATCH": "BOM 原料庫存單位與貨品主檔不一致", "masterDataIssue_BOM_MATERIAL_UOM_FK_INVALID": "BOM 原料 UOM 參照無效或已刪除", + "masterDataIssue_BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX": "貨品基本單位不在標準矩陣內,無法由配方單位換算", + "masterDataIssue_BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE": "BOM 原料配方單位無法換算至貨品基本單位", + "masterDataIssue_BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX": "BOM 原料配方單位不在標準矩陣內,且無法自動換算", + "masterDataIssue_BOM_MATERIAL_DERIVE_FAILED": "BOM 原料無法由配方用量推算各單位數量", "masterDataIssue_BOM_ITEM_CODE_MISMATCH": "BOM 編號與關聯貨品不一致", "masterDataIssue_BOM_ITEM_NAME_MISMATCH": "BOM 產品名稱與關聯貨品不一致", "masterDataIssue_col_linked_item": "關聯貨品", @@ -73,7 +81,7 @@ "masterDataIssue_bomMore": " 等 {{count}} 個", "masterDataIssue_col_problem": "問題", "masterDataIssue_col_bom_uom": "BOM 單位", - "masterDataIssue_col_item_uom": "M18 單位", + "masterDataIssue_col_item_uom": "M18單位", "masterDataIssue_modifiedAt": "修改時間", "masterDataIssue_unit_active": "使用中", "masterDataIssue_unit_inactive": "已停用", @@ -85,6 +93,7 @@ "masterDataIssue_unit_picking": "揀貨單位", "masterDataIssue_unit_purchase": "採購單位", "masterDataIssue_unit_output": "產出單位", + "masterDataIssue_unit_recipe": "配方單位", "masterDataIssue_line_itemMissing": "缺少:{{units}}", "masterDataIssue_line_itemGeneric": "{{problem}}(應為「{{expected}}」,實際「{{actual}}」)", "masterDataIssue_line_uomBoth": "{{bom}}:銷售/庫存單位應為「{{expected}}」,BOM 為「{{actual}}」", @@ -104,11 +113,11 @@ "masterDataIssue_line_pairSales": "銷售單位應為「{{expected}}」,BOM 為「{{actual}}」", "masterDataIssue_line_pairStock": "庫存單位應為「{{expected}}」,BOM 為「{{actual}}」", "masterDataIssue_line_pairBase": "基本單位應為「{{expected}}」,BOM 為「{{actual}}」", - "masterDataIssue_align_preview": "預覽修正(對齊 M18)", - "masterDataIssue_align_row": "對齊 M18", - "masterDataIssue_align_title": "預覽:BOM 單位對齊 M18", - "masterDataIssue_align_info_m18": "若 M18 貨品主檔為準:可使用本功能將 BOM 單位與數量對齊 M18。BOM 表頭產出數量預設不變;原料銷售/庫存/基本數量修改後將按 item_uom 換算聯動。配方用量可單獨修改,與銷售/庫存/基本無聯動。", - "masterDataIssue_align_info_excel": "若 BOM Excel 才是正確來源:請勿使用本功能修改 BOM,應在 M18 同步中更新該貨品的銷售/庫存單位。", + "masterDataIssue_align_preview": "預覽修正(換算)", + "masterDataIssue_align_row": "修正換算", + "masterDataIssue_align_title": "預覽:修正 BOM 單位換算", + "masterDataIssue_align_info_m18": "若 M18 貨品主檔為準:可使用本功能修正無法換算至 M18 單位的 BOM 資料。表頭產出以基本單位儲存,並顯示庫存單位作參考;原料以配方用量+配方單位為主,修改後自動推算基本/庫存數量。", + "masterDataIssue_align_info_excel": "若 BOM Excel 才是正確來源:請勿使用本功能修改 BOM,應在 M18 同步中更新該貨品的單位設定。", "masterDataIssue_align_summary": "可修正:BOM 表頭 {{headers}} 筆 · 原料 {{materials}} 筆 · 略過 {{skipped}} 筆", "masterDataIssue_align_tab_headers": "BOM 總表 ({{count}})", "masterDataIssue_align_tab_materials": "BOM 原材料 ({{count}})", @@ -118,8 +127,15 @@ "masterDataIssue_align_field": "欄位", "masterDataIssue_align_after": "修正後", "masterDataIssue_align_outputQty": "產出數量", + "masterDataIssue_align_outputQtyBase": "產出數量(基本)", + "masterDataIssue_align_outputQtyStock": "產出數量(庫存)", + "masterDataIssue_align_outputBase": "產出(基本)", + "masterDataIssue_align_outputStock": "產出(庫存)", + "masterDataIssue_align_outputUomBase": "產出單位(基本)", + "masterDataIssue_align_outputUomStock": "產出單位(庫存)", + "masterDataIssue_align_reference": "僅供參考", "masterDataIssue_align_recipeQty": "配方用量", - "masterDataIssue_align_confirm": "我已確認應以 M18 貨品主檔為準", + "masterDataIssue_align_confirm": "我已確認應以 M18 貨品主檔為準,並了解修正內容", "masterDataIssue_align_apply": "確認套用", "masterDataIssue_align_applying": "套用中…", "masterDataIssue_align_loadFailed": "無法載入修正預覽。", @@ -127,7 +143,6 @@ "masterDataIssue_align_not_set": "未設定", "masterDataIssue_align_warn_1to1": "BOM 銷售單位無法依 M18 換算(可能為舊 Excel 匯入錯誤單位),已暫以 1:1 預填銷售數量,請確認後再套用。", "masterDataIssue_align_warn_bom_qty_null": "BOM 左側數量未設定({{fields}})。可採用右側 M18 建議值,或修正 Excel 後重匯。", - "masterDataIssue_align_warn_field_sale": "銷售數量", - "masterDataIssue_align_warn_field_stock": "庫存數量", - "masterDataIssue_align_warn_field_base": "基本數量" + "masterDataIssue_align_warn_field_recipe_qty": "配方用量", + "masterDataIssue_align_warn_field_recipe_uom": "配方單位" } diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index 70c05d3..157f0d3 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -17,6 +17,7 @@ "Base UOM": "基本單位", "Batch Count": "批數", "BoM Material": "BOM 材料", + "Bom Product Attributes": "BOM 產品屬性", "Bom Req. Qty": "BOM", "Bom Uom": "BOM 單位", "By-product": "副產品", @@ -26,6 +27,11 @@ "Changeover Time": "生產後轉換時間", "Changeover Time (mins)": "生產後轉換時間(分鐘)", "Code": "編號", + "Depth label": "顔色深淺度 深1淺5", + "Density label": "濃淡 濃3淡5", + "Float label": "浮沉 浮3沉5", + "Time Sequence label": "時段 下午1上午5", + "Complexity label": "複雜度 簡單10 中度5 複雜3", "Complete Step": "完成步驟", "Completed": "完成", "Completed Step": "完成步驟", @@ -66,7 +72,7 @@ "stopped": "已停止", "Invalid Job Order Id": "無效工單編號", "Invalid Stock In Line Id": "無效庫存行ID", - "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 濃淡 | 浮沉 | 損耗率 | 過敏原 | 時間次序 | 複雜度", + "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 浮沉 | 濃淡 | 損耗率 | 過敏原 | 時間次序 | 複雜度", "Item": "物品", "Item Code": "貨品編號", "Item Name": "物品名稱", @@ -121,7 +127,7 @@ "Previous page": "上一頁", "Printer": "列印機", "Process": "工序", - "Process & Equipment": "製程與設備", + "Process & Equipment": "工序與設備", "Process Description": "工序說明", "Process Name": "工序名稱", "Process Start Time": "工序開始時間",