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": "工序開始時間",