소스 검색

Import Bom Excel check

if excel have issue will show and can veiw import data and can edit correct, and till isuue fix other wise not allow save.
issue import bom check those check
If Is FG but item LocationCode is null.

Bom detail
can edit bom
have version compare
can switch version of bom and select version to active/inactive

排程
imporve efficent of predect排程, export excel function, and else

BOM/貨品單位問題
if current active bom uom  can not convert to item base uom, will be issue show in this page
if current active bom's bommaterail qty can not convert to item base uom , will be issue show in this page
have fix form to update bom/bom materail data to help convert to base uom correct

jobOrder Create
if Bom have 單位問題, will not allow create Job Order

bom martail remove column base/stock/sale qty/uom
bom qty/uom from store stock change to base
And these two have realte convert function to convert back realte uom/qty need to use
bomUpdateTest
CANCERYS\kw093 1 주 전
부모
커밋
85b482d918
51개의 변경된 파일8768개의 추가작업 그리고 1111개의 파일을 삭제
  1. +9
    -2
      src/app/(main)/jo/page.tsx
  2. +1
    -1
      src/app/(main)/settings/importBom/page.tsx
  3. +147
    -0
      src/app/api/bom/client.ts
  4. +163
    -1
      src/app/api/bom/index.ts
  5. +6
    -6
      src/app/api/jo/actions.ts
  6. +97
    -0
      src/app/api/masterDataIssues/bomUomAlignment.ts
  7. +76
    -0
      src/app/api/masterDataIssues/client.ts
  8. +8
    -0
      src/app/api/masterDataIssues/index.ts
  9. +543
    -0
      src/components/ImportBom/BomAttributeScaleDisplay.tsx
  10. +797
    -0
      src/components/ImportBom/BomBasicInfoSection.tsx
  11. +437
    -0
      src/components/ImportBom/BomImportMaterialEditTable.tsx
  12. +1169
    -0
      src/components/ImportBom/BomImportPreviewDialog.tsx
  13. +456
    -0
      src/components/ImportBom/BomMaterialEditPanel.tsx
  14. +777
    -0
      src/components/ImportBom/BomProcessEditPanel.tsx
  15. +61
    -0
      src/components/ImportBom/BomPutawayLocationFields.tsx
  16. +234
    -0
      src/components/ImportBom/BomVersionControlBar.tsx
  17. +376
    -752
      src/components/ImportBom/ImportBomDetailTab.tsx
  18. +542
    -145
      src/components/ImportBom/ImportBomResultForm.tsx
  19. +1
    -0
      src/components/ImportBom/ImportBomUpload.tsx
  20. +501
    -0
      src/components/ImportBom/bomAttributeScales.ts
  21. +117
    -0
      src/components/ImportBom/bomDiff.tsx
  22. +205
    -0
      src/components/ImportBom/bomImportFormatFieldErrors.ts
  23. +34
    -0
      src/components/ImportBom/bomImportKindMode.ts
  24. +377
    -0
      src/components/ImportBom/bomImportPreviewUtils.ts
  25. +115
    -0
      src/components/ImportBom/bomProcessUtils.ts
  26. +43
    -0
      src/components/ImportBom/bomVersionCompare.ts
  27. +100
    -0
      src/components/ImportBom/bomVersionSaveUtils.ts
  28. +46
    -0
      src/components/ImportBom/bomVersionUtils.ts
  29. +52
    -0
      src/components/ImportBom/putawayLocationUtils.ts
  30. +11
    -3
      src/components/JoSearch/JoCreateFormModal.tsx
  31. +9
    -1
      src/components/JoSearch/JoSearch.tsx
  32. +18
    -2
      src/components/JoSearch/JoSearchWrapper.tsx
  33. +9
    -1
      src/components/JoWorkbench/JoWorkbenchSearch.tsx
  34. +808
    -0
      src/components/MasterDataIssues/BomUomAlignmentDialog.tsx
  35. +111
    -16
      src/components/MasterDataIssues/MasterDataIssuesPanel.tsx
  36. +9
    -7
      src/components/MasterDataIssues/MasterDataIssuesTabs.tsx
  37. +34
    -116
      src/components/MasterDataIssues/buildDisplayLines.ts
  38. +4
    -7
      src/components/MasterDataIssues/groupMasterDataIssues.ts
  39. +55
    -0
      src/components/ProductionProcess/JobOrderBomAttributesGrid.tsx
  40. +4
    -9
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  41. +1
    -1
      src/components/ScheduleTable/BomMaterialTable.tsx
  42. +1
    -1
      src/components/Shop/Shop.tsx
  43. +6
    -4
      src/hooks/useMasterDataIssueNavCount.ts
  44. +10
    -1
      src/i18n/en/importBom.json
  45. +3
    -2
      src/i18n/en/jo.json
  46. +25
    -10
      src/i18n/en/masterDataIssue.json
  47. +2
    -1
      src/i18n/en/productionProcess.json
  48. +113
    -7
      src/i18n/zh/importBom.json
  49. +12
    -3
      src/i18n/zh/jo.json
  50. +25
    -10
      src/i18n/zh/masterDataIssue.json
  51. +8
    -2
      src/i18n/zh/productionProcess.json

+ 9
- 2
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<Record<number, number>>((acc, issue) => {
const id = issue.bomId;
acc[id] = (acc[id] ?? 0) + 1;
return acc;
}, {});

return (
<>
@@ -37,6 +43,7 @@ const Jo: React.FC = async () => {
<JoWorkbenchSearch
defaultInputs={defaultInputs}
bomCombo={bomCombo ?? []}
bomIssueCountByBomId={bomIssueCountByBomId}
printerCombo={printerCombo ?? []}
jobTypes={jobTypes ?? []}
/>


+ 1
- 1
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() {


+ 147
- 0
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<BomImportPreviewResponse> {
const response = await axiosInstance.post<BomImportPreviewResponse>(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/preview`,
{ batchId, fileNames },
);
return response.data;
}

export async function revalidateImportBomItemClient(
batchId: string,
fileName: string,
overrides?: BomImportItemOverrides,
): Promise<BomImportRevalidateResponse> {
const response = await axiosInstance.post<BomImportRevalidateResponse>(
`${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<BomImportMaterialItemContext> {
const response = await axiosInstance.get<BomImportMaterialItemContext>(
`${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<BomDetailResponse> {
const response = await axiosInstance.post<BomDetailResponse>(
`${NEXT_PUBLIC_API_URL}/bom/${id}/activate-version`,
);
return response.data;
}

export async function fetchBomVersionsClient(
code: string,
bomKind: string,
): Promise<BomVersionSummary[]> {
const response = await axiosInstance.get<BomVersionSummary[]>(
`${NEXT_PUBLIC_API_URL}/bom/versions`,
{ params: { code, bomKind } },
);
return response.data;
}

export async function saveBomAsNewVersionClient(
sourceBomId: number,
request: SaveBomAsNewVersionRequest,
): Promise<BomDetailResponse> {
const response = await axiosInstance.post<BomDetailResponse>(
`${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<Array<{ code: string; name: string }>>(
`${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<Array<{ exists: boolean; code: string; name?: string }>> {
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[]
> {


+ 163
- 1
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<BomComboIssue[]>(`${BASE_API_URL}/bom/combo/issues`, {
next: { tags: ["bomComboIssues"] },
});
});

export const fetchBomScores = cache(async () => {
return serverFetchJson<BomScoreResult[]>(`${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[];


+ 6
- 6
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;


+ 97
- 0
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;
}

+ 76
- 0
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<MasterDataIssue[]> {
const response = await axiosInstance.get<MasterDataIssue[]>(
@@ -27,3 +34,72 @@ export async function fetchMasterDataIssuesSummaryClient(
);
return response.data;
}

export async function previewBomUomAlignmentClient(params?: {
bomIds?: number[];
bomMaterialIds?: number[];
}): Promise<BomUomAlignmentPreviewResponse> {
const response = await axiosInstance.post<BomUomAlignmentPreviewResponse>(
`${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<BomMaterialQtyRecalculateResponse> {
const response = await axiosInstance.post<BomMaterialQtyRecalculateResponse>(
`${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<BomHeaderOutputQtyRecalculateResponse> {
const response = await axiosInstance.post<BomHeaderOutputQtyRecalculateResponse>(
`${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<BomUomAlignmentApplyResponse> {
const response = await axiosInstance.post<BomUomAlignmentApplyResponse>(
`${NEXT_PUBLIC_API_URL}/bom/master-data/uom-alignment/apply`,
body,
);
return response.data;
}

+ 8
- 0
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"


+ 543
- 0
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 (
<Box
sx={{
position: "absolute",
left: `calc(${markerLeftPercent(value, railMin, railMax)}% - ${size / 2}px)`,
width: size,
height: size,
borderRadius: "50%",
bgcolor: "#fff",
border: `3px solid ${borderColor}`,
boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
zIndex: variant === "new" ? 3 : variant === "old" ? 2 : 2,
pointerEvents: "none",
}}
/>
);
}

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 (
<Box sx={{ px: `${thumbPad}px`, py: compact ? 0.5 : 1 }}>
<Box
sx={{
position: "relative",
height: compact ? THUMB_SIZE_COMPACT : THUMB_SIZE,
display: "flex",
alignItems: "center",
}}
>
<Box
sx={{
position: "absolute",
left: 0,
right: 0,
height: railHeight,
borderRadius: railHeight / 2,
background: SCALE_RAIL_BG,
}}
/>
{children}
</Box>
</Box>
);
}

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 (
<Box sx={{ minWidth: compact ? 140 : 200 }}>
{showFieldLabel && (
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{label}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
不適用
</Typography>
</Box>
);
}

return (
<Box sx={{ minWidth: compact ? 140 : steps ? 220 : 200 }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="baseline"
sx={{ mb: 0.5, minHeight: 24 }}
>
{showFieldLabel ? (
<Typography variant="body2" fontWeight={600}>
{label}
</Typography>
) : (
<Box />
)}
<Typography
variant="body2"
fontWeight={600}
sx={{ textAlign: "right", flex: 1, ml: 1 }}
>
{scaleCaption(captionValue, config)}
</Typography>
</Stack>

<Slider
size="small"
min={railMin}
max={railMax}
step={steps ? null : 1}
marks={
steps
? steps.map((step) => ({
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 ? (
<Stack
direction="row"
justifyContent="space-between"
sx={{ mt: 0.25, px: 0.5, gap: 0.5 }}
>
{steps.map((step) => (
<Typography
key={step}
variant="caption"
color="text.secondary"
sx={{
fontSize: compact ? 10 : 11,
lineHeight: 1.2,
whiteSpace: "nowrap",
}}
>
{discreteStepLabel(step, config)}
</Typography>
))}
</Stack>
) : (
<Stack direction="row" justifyContent="space-between" sx={{ mt: -0.5 }}>
<Typography variant="caption" color="text.secondary">
{config.lowLabel}
</Typography>
<Typography variant="caption" color="text.secondary">
{config.highLabel}
</Typography>
</Stack>
)}
</Box>
);
}

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 (
<Box sx={{ minWidth: compact ? 140 : steps ? 220 : 200 }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="baseline"
sx={{ mb: 0.5, minHeight: 24 }}
>
<Typography variant="body2" fontWeight={600}>
{label}
</Typography>
<Typography
variant="body2"
fontWeight={600}
component="div"
sx={{ textAlign: "right", flex: 1, ml: 1 }}
>
<Box
component="span"
sx={{ color: COMPARE_OLD_COLOR.color, mr: 0.75 }}
>
{oldCaption}
</Box>
<Box component="span" sx={{ color: COMPARE_NEW_COLOR.color }}>
{newCaption}
</Box>
</Typography>
</Stack>

<ScaleTrack compact={compact}>
<ScaleMarker
value={oldValue}
railMin={railMin}
railMax={railMax}
variant="old"
compact={compact}
/>
<ScaleMarker
value={newValue}
railMin={railMin}
railMax={railMax}
variant="new"
compact={compact}
/>
</ScaleTrack>

{steps && steps.length > 0 ? (
<Stack
direction="row"
justifyContent="space-between"
sx={{ mt: 0.25, px: compact ? "9px" : "11px", gap: 0.5 }}
>
{steps.map((step) => (
<Typography
key={step}
variant="caption"
color="text.secondary"
sx={{ fontSize: compact ? 10 : 11, lineHeight: 1.2, whiteSpace: "nowrap" }}
>
{discreteStepLabel(step, config)}
</Typography>
))}
</Stack>
) : (
<Stack direction="row" justifyContent="space-between" sx={{ mt: -0.5 }}>
<Typography variant="caption" color="text.secondary">
{config.lowLabel}
</Typography>
<Typography variant="caption" color="text.secondary">
{config.highLabel}
</Typography>
</Stack>
)}
</Box>
);
}

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 (
<CompareScaleBar
label={label}
config={config}
oldValue={displayOld}
newValue={displayValue}
allowedSteps={allowedSteps ?? undefined}
/>
);
}

return (
<ScaleBar
label={label}
config={config}
value={displayValue}
readOnly
allowedSteps={allowedSteps ?? undefined}
/>
);
}

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 (
<Box sx={{ minWidth: compact ? 0 : 200, flex: 1, width: "100%" }}>
{showNaToggle && (
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 0.5 }}>
<Typography
variant="caption"
component="button"
type="button"
onClick={() => onChange(isNa ? firstNonZeroStep : 0)}
sx={{
border: "none",
background: "none",
cursor: "pointer",
color: isNa ? "primary.main" : "text.secondary",
textDecoration: "underline",
}}
>
{isNa ? "設定刻度" : "不適用"}
</Typography>
</Stack>
)}
{isNa ? (
<>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{label}
</Typography>
<Typography variant="body2" color="text.secondary">
不適用
</Typography>
</>
) : (
<ScaleBar
label={label}
config={config}
value={value}
readOnly={false}
onChange={onChange}
compact={compact}
allowedSteps={allowedSteps}
/>
)}
</Box>
);
}

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 (
<FormControl
fullWidth
size="small"
error={error || !isValid}
sx={{ minWidth: compact ? 0 : 160 }}
>
<InputLabel>{label}</InputLabel>
<Select
label={label}
value={selectValue}
onChange={(e) => {
const next = Number(e.target.value);
if (next !== INVALID_SCALE_SENTINEL) onChange(next);
}}
>
{!isValid && (
<MenuItem value={INVALID_SCALE_SENTINEL} disabled>
{`${value}(無效,請重選)`}
</MenuItem>
)}
{options.map((opt) => (
<MenuItem key={opt} value={opt}>
{captionForAllowedScaleValue(field, opt)}
{opt !== 0 ? ` (${opt})` : ""}
</MenuItem>
))}
</Select>
{(helperText || !isValid) ? (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}>
{helperText ?? `${label}數值無效,請重選`}
</Typography>
) : null}
</FormControl>
);
}

+ 797
- 0
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<string>,
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<BomDetailResponse> {
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<BomBasicInfoSectionHandle, Props>(
function BomBasicInfoSection(
{
detail,
compareOldDetail,
comparing = false,
editDisabled = false,
onDirtyChange,
},
ref,
) {
const { t } = useTranslation("importBom");
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<BasicInfoDraft | null>(null);
const [outputQtyStockInput, setOutputQtyStockInput] = useState("");
const [outputQtyStockLoading, setOutputQtyStockLoading] = useState(false);
const [outputQtyStockError, setOutputQtyStockError] = useState<string | null>(null);
const outputQtyStockInFlightRef = useRef(false);
const [warehouseCodes, setWarehouseCodes] = useState<ReadonlySet<string>>(
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) => (
<DiffValue comparing={comparing} oldVal={oldVal} newVal={newVal} />
);

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 (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 1 }}
>
<Typography variant="subtitle1">{t("Basic Info")}</Typography>

{!isEditing && !comparing ? (
<Button
size="small"
startIcon={<EditIcon />}
variant="outlined"
onClick={startEdit}
disabled={editDisabled}
>
{t("Edit Basic Info")}
</Button>
) : isEditing ? (
<Button
size="small"
startIcon={<CancelIcon />}
variant="outlined"
onClick={cancelEdit}
>
{t("Cancel")}
</Button>
) : null}
</Stack>

{!isEditing && (
<Stack spacing={1}>
<Typography variant="body2" component="div">
{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),
)}
</Typography>

{headerOutputQtyIssue && (
<Typography variant="body2" color="warning.main">
{t("bomHeaderOutputQtyStockConvertFail_warn")}
</Typography>
)}

<Typography variant="body2" component="div">
{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 ?? "-"),
)}
</Typography>

{isFg && (
<Typography variant="body2" component="div">
{t("Putaway Location")}:{" "}
{diffField(
formatPutawayLocationDisplay(compareOldDetail?.putawayLocationCode),
formatPutawayLocationDisplay(detail.putawayLocationCode),
)}
</Typography>
)}

<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleDisplay
label={t("Depth")}
field="isDark"
allowedSteps={allowedStepsForField("isDark", { includeNa: false })}
config={COLOR_DEPTH_SCALE}
value={detail.isDark}
compareOldValue={compareOldDetail?.isDark}
comparing={comparing}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleDisplay
label={t("Float")}
field="isFloat"
config={FLOAT_SINK_SCALE}
value={detail.isFloat}
compareOldValue={compareOldDetail?.isFloat}
comparing={comparing}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleDisplay
label={t("Density")}
field="isDense"
config={DENSITY_SCALE}
value={detail.isDense}
compareOldValue={compareOldDetail?.isDense}
comparing={comparing}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleDisplay
label={t("Complexity")}
field="complexity"
config={COMPLEXITY_SCALE}
value={detail.complexity}
compareOldValue={compareOldDetail?.complexity}
comparing={comparing}
/>
</Paper>
</Grid>
</Grid>
</Stack>
)}

{isEditing && draft && (
<Stack spacing={2}>
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
alignItems="flex-end"
>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>{t("Type")}</InputLabel>
<Select
label={t("Type")}
value={draft.bomKind}
onChange={(e) =>
setDraft((p) =>
p ? { ...p, bomKind: e.target.value as "FG" | "WIP" } : p,
)
}
>
<MenuItem value="FG">成品</MenuItem>
<MenuItem value="WIP">半成品</MenuItem>
</Select>
</FormControl>

<FormControl size="small" sx={{ width: 140 }}>
<InputLabel htmlFor="bom-output-qty">
{t("Output Quantity")}
</InputLabel>
<OutlinedInput
id="bom-output-qty"
type="number"
label={t("Output Quantity")}
value={outputQtyStockInput}
onChange={(e) => {
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" }}
/>
</FormControl>

<Typography
variant="body2"
color="text.secondary"
sx={{ pb: 1, minWidth: 72 }}
>
{detail.outputQtyStockUom ?? "-"}
</Typography>

{headerOutputQtyIssue && (
<Typography variant="body2" color="warning.main" sx={{ pb: 1 }}>
{t("bomHeaderOutputQtyStockConvertFail_warn")}
</Typography>
)}

<Typography
variant="body2"
color={outputQtyStockError ? "error.main" : "text.secondary"}
sx={{ pb: 1, minWidth: 180 }}
>
{t("Base Qty")}: {draft.outputQty ?? "-"} {draft.outputQtyUom ?? ""}
{outputQtyStockLoading ? "(換算中)" : ""}
</Typography>

<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>{t("BOM Status")}</InputLabel>
<Select
label={t("BOM Status")}
value={draft.status}
onChange={(e) =>
setDraft((p) =>
p ? { ...p, status: e.target.value as BomStatus } : p,
)
}
>
<MenuItem value="active">{t("BOM Status Active")}</MenuItem>
<MenuItem value="inactive">{t("BOM Status Inactive")}</MenuItem>
</Select>
</FormControl>

<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>{t("Allergic Substances")}</InputLabel>
<Select
label={t("Allergic Substances")}
value={draft.allergicSubstances}
onChange={(e) =>
setDraft((p) =>
p
? { ...p, allergicSubstances: Number(e.target.value) }
: p,
)
}
>
<MenuItem value={0}>有</MenuItem>
<MenuItem value={5}>沒有</MenuItem>
</Select>
</FormControl>

<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>{t("Time Sequence")}</InputLabel>
<Select
label={t("Time Sequence")}
value={draft.timeSequence}
onChange={(e) =>
setDraft((p) =>
p ? { ...p, timeSequence: Number(e.target.value) } : p,
)
}
>
<MenuItem value={5}>上午</MenuItem>
<MenuItem value={1}>下午</MenuItem>
<MenuItem value={0}>不適用</MenuItem>
</Select>
</FormControl>

<Typography variant="body2" sx={{ pb: 1 }}>
{t("Base Score")}: {detail.baseScore ?? "-"}
</Typography>
</Stack>

{draft.bomKind === "FG" && (
<Stack spacing={0.5}>
<Typography variant="body2" color="text.secondary">
{t("Putaway Location")}
</Typography>
<BomPutawayLocationFields
value={draft.putawayLocation}
onChange={(putawayLocation) =>
setDraft((p) => (p ? { ...p, putawayLocation } : p))
}
error={validatePutawayLocationParts(
draft.putawayLocation,
warehouseCodes,
)}
/>
<Typography variant="caption" color="text.secondary">
{t("Putaway edit creates new version hint")}
</Typography>
</Stack>
)}

<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleSlider
label={t("Depth")}
config={COLOR_DEPTH_SCALE}
value={draft.isDark}
allowedSteps={allowedStepsForField("isDark", { includeNa: false })}
onChange={(v) =>
setDraft((p) => (p ? { ...p, isDark: v } : p))
}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleSlider
label={t("Float")}
config={FLOAT_SINK_SCALE}
value={draft.isFloat}
allowedSteps={allowedStepsForField("isFloat")}
onChange={(v) =>
setDraft((p) => (p ? { ...p, isFloat: v } : p))
}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleSlider
label={t("Density")}
config={DENSITY_SCALE}
value={draft.isDense}
allowedSteps={allowedStepsForField("isDense")}
onChange={(v) =>
setDraft((p) => (p ? { ...p, isDense: v } : p))
}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<BomAttributeScaleSlider
label={t("Complexity")}
config={COMPLEXITY_SCALE}
value={draft.complexity}
allowedSteps={allowedStepsForField("complexity")}
onChange={(v) =>
setDraft((p) => (p ? { ...p, complexity: v } : p))
}
/>
</Paper>
</Grid>
</Grid>
</Stack>
)}
</Paper>
);
},
);

+ 437
- 0
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<string, MaterialRowFormatErrors>;
onRowsChange: React.Dispatch<React.SetStateAction<ImportMaterialEditRow[]>>;
};

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<Record<string, ReturnType<typeof setTimeout>>>({});
const itemCodeTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const itemCodeReqSeqRef = useRef<Record<string, number>>({});

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<ImportMaterialEditRow>) => {
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 (
<Typography color="text.secondary">{t("No materials parsed")}</Typography>
);
}

return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell align="right">{t("Recipe Qty")}</TableCell>
<TableCell>{t("Recipe UOM")}</TableCell>
<TableCell align="right">{t("Base Qty")}</TableCell>
<TableCell align="right">{t("Stock Qty")}</TableCell>
<TableCell>{t("Join Step")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={row.key}>
<TableCell>
<TextField
size="small"
value={row.itemCode}
onChange={(e) => {
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 }}
/>
</TableCell>
<TableCell>{row.itemName || "-"}</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={row.qty}
onChange={(e) =>
updateRow(row.key, { qty: Number(e.target.value) })
}
error={Boolean(qtyError)}
helperText={qtyError}
inputProps={{ min: 0, step: "any" }}
sx={{ width: 100 }}
/>
</TableCell>
<TableCell>
{!showUomSelect ? (
<Typography
variant="body2"
color={uomError ? "error" : undefined}
>
{row.availableRecipeUoms.find((u) => u.uomId === row.uomId)
?.label ??
row.availableRecipeUoms[0]?.label ??
(row.uomCode || "-")}
{uomError && (
<Typography variant="caption" color="error" display="block">
{uomError}
</Typography>
)}
</Typography>
) : (
<FormControl
size="small"
sx={{ minWidth: 120 }}
error={Boolean(uomError)}
>
<Select
value={row.uomId || ""}
displayEmpty
onChange={(e) =>
updateRow(row.key, {
uomId: Number(e.target.value),
})
}
>
{row.availableRecipeUoms.length === 0 && row.uomCode ? (
<MenuItem value={row.uomId || ""}>
{row.uomCode}
</MenuItem>
) : null}
{row.availableRecipeUoms.map((u) => (
<MenuItem key={u.uomId} value={u.uomId}>
{u.label}
</MenuItem>
))}
</Select>
{uomError && (
<Typography variant="caption" color="error" sx={{ mt: 0.5 }}>
{uomError}
</Typography>
)}
</FormControl>
)}
</TableCell>
<TableCell align="right">
{fmtQtyUom(row.previewBaseQty, row.baseUom)}
</TableCell>
<TableCell align="right">
{fmtQtyUom(row.previewStockQty, row.stockUom)}
</TableCell>
<TableCell>
{processStepOptions.length === 0 ? (
row.joinStepSeqNo ?? "-"
) : (
<FormControl
size="small"
sx={{ minWidth: 160 }}
error={Boolean(joinStepError)}
>
<Select
displayEmpty
value={row.joinStepSeqNo ?? ""}
onChange={(e) => {
const v = e.target.value;
updateRow(row.key, {
joinStepSeqNo:
v === "" ? null : Number(v),
});
}}
>
<MenuItem value="">
<em>—</em>
</MenuItem>
{processStepOptions.map((p) => (
<MenuItem key={p.seqNo} value={p.seqNo}>
{p.label}
</MenuItem>
))}
</Select>
{joinStepError && (
<Typography variant="caption" color="error" sx={{ mt: 0.5 }}>
{joinStepError}
</Typography>
)}
</FormControl>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

+ 1169
- 0
src/components/ImportBom/BomImportPreviewDialog.tsx
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 456
- 0
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<BomMaterialEditPanelHandle, Props>(
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<MaterialEditRow[]>([]);
const previewTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});

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<MaterialEditRow>) => {
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 (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1">{t("Bom Material")}</Typography>
{!isEditing ? (
<Button
size="small"
startIcon={<EditIcon />}
variant="outlined"
onClick={startEdit}
disabled={editDisabled}
>
{t("Edit Materials")}
</Button>
) : (
<Button size="small" startIcon={<CancelIcon />} variant="outlined" onClick={cancelEdit}>
{t("Cancel")}
</Button>
)}
</Stack>

<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell align="right">{t("Recipe Qty")}</TableCell>
<TableCell>{t("Recipe UOM")}</TableCell>
<TableCell align="right">{t("Base Qty")}</TableCell>
<TableCell align="right">{t("Stock Qty")}</TableCell>
<TableCell>{t("Join Step")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isEditing
? rows.map((row) => (
<TableRow key={row.key}>
<TableCell>{row.itemCode}</TableCell>
<TableCell>{row.itemName}</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={row.qty}
onChange={(e) =>
updateRow(row.key, { qty: Number(e.target.value) })
}
inputProps={{ min: 0, step: "any" }}
sx={{ width: 100 }}
/>
</TableCell>
<TableCell>
{row.availableRecipeUoms.length <= 1 ? (
<Typography variant="body2">
{row.availableRecipeUoms.find((u) => u.uomId === row.uomId)
?.label ??
row.availableRecipeUoms[0]?.label ??
"-"}
</Typography>
) : (
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={row.uomId}
onChange={(e) =>
updateRow(row.key, { uomId: Number(e.target.value) })
}
>
{row.availableRecipeUoms.map((u) => (
<MenuItem key={u.uomId} value={u.uomId}>
{u.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
</TableCell>
<TableCell align="right">
{fmtQtyUom(row.previewBaseQty, row.baseUom)}
</TableCell>
<TableCell align="right">
{fmtQtyUom(row.previewStockQty, row.stockUom)}
</TableCell>
<TableCell>
{processOptions.length === 0 ? (
fmtProcessSteps(row.processSteps)
) : (
<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
multiple
value={row.processStepIds}
onChange={(e) => {
const nextIds = e.target.value as number[];
updateRow(row.key, {
processStepIds: nextIds,
processSteps: labelsForProcessIds(processOptions, nextIds),
});
}}
renderValue={(selected) =>
fmtProcessSteps(
labelsForProcessIds(processOptions, selected as number[]),
)
}
>
{processOptions.map((p) => (
<MenuItem key={p.id} value={p.id}>
<Checkbox checked={row.processStepIds.includes(p.id)} />
<ListItemText primary={p.label} />
</MenuItem>
))}
</Select>
</FormControl>
)}
</TableCell>
</TableRow>
))
: detail.materials.map((m, i) => {
const oldM = oldMaterialMap.get(m.itemCode ?? "");
return (
<TableRow key={`${m.id ?? i}-${m.itemCode}`}>
<TableCell>{m.itemCode}</TableCell>
<TableCell>{m.itemName}</TableCell>
<TableCell align="right">
{materialDiffCell(
oldM,
m,
(row) => fmtQty(row.recipeQty ?? row.baseQty),
comparing,
)}
</TableCell>
<TableCell>
{materialDiffCell(
oldM,
m,
(row) => row.recipeUom ?? row.baseUom ?? "-",
comparing,
)}
</TableCell>
<TableCell align="right">
{materialDiffCell(
oldM,
m,
(row) => fmtQtyUom(row.baseQty, row.baseUom),
comparing,
)}
</TableCell>
<TableCell align="right">
{materialDiffCell(
oldM,
m,
(row) => fmtQtyUom(row.stockQty, row.stockUom),
comparing,
)}
</TableCell>
<TableCell>
{materialDiffCell(
oldM,
m,
(row) => fmtProcessSteps(row.processSteps),
comparing,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{isEditing && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block" }}>
{t("Material edit creates new version hint")}
</Typography>
)}
</Paper>
);
},
);

+ 777
- 0
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<string | null>;
getProcessSaveLines: () => ReturnType<typeof buildProcessSaveLine>[];
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<BomProcessEditPanelHandle, Props>(
function BomProcessEditPanel(
{ detail, compareOldDetail = null, comparing = false, onDirtyChange },
ref,
) {
const { t } = useTranslation("importBom");
const [isEditing, setIsEditing] = useState(false);
const [rows, setRows] = useState<ProcessEditRow[]>([]);
const [masterLoading, setMasterLoading] = useState(false);
const [masterError, setMasterError] = useState<string | null>(null);
const [equipmentList, setEquipmentList] = useState<EquipmentMasterRow[]>([]);
const [processList, setProcessList] = useState<ProcessMasterRow[]>([]);
const [bagItems, setBagItems] = useState<BagItemCombo[]>([]);
const [byProductErrors, setByProductErrors] = useState<Record<string, string>>({});
const startInFlightRef = useRef(false);
const byProductCheckInFlightRef = useRef<Record<string, boolean>>({});

const equipmentDescriptionOptions = useMemo(() => {
const s = new Set<string>();
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<string | null> => {
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<string | null> => {
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<ProcessEditRow>) => {
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 (
<Stack direction="column" spacing={0.5} sx={{ minWidth: 160, ...editFieldSx }}>
<FormControl size="small" fullWidth sx={editFieldSx}>
<InputLabel shrink>{t("Equipment Description")}</InputLabel>
<Select
notched
label={t("Equipment Description")}
displayEmpty
value={row.equipmentDescription}
onChange={(e) =>
updateRow(row.key, {
equipmentDescription: String(e.target.value),
equipmentName: String(e.target.value).trim() ? row.equipmentName : "",
})
}
>
<MenuItem value="">
<em>—</em>
</MenuItem>
{equipmentDescriptionOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" fullWidth disabled={!hasDescription} sx={editFieldSx}>
<InputLabel shrink>{t("Equipment Name")}</InputLabel>
<Select
notched
label={t("Equipment Name")}
displayEmpty
value={hasDescription ? row.equipmentName : ""}
onChange={(e) =>
updateRow(row.key, { equipmentName: String(e.target.value) })
}
>
{!hasDescription ? (
<MenuItem value="">
<em>—</em>
</MenuItem>
) : (
nameOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
</MenuItem>
))
)}
</Select>
</FormControl>
</Stack>
);
};

const renderDescriptionCell = (row: ProcessEditRow) => {
if (isPackagingProcess(row.processCode)) {
return (
<FormControl size="small" fullWidth sx={packagingSelectSx}>
<Select
multiple
displayEmpty
value={row.selectedBagCodes}
onChange={(e) => {
const value = e.target.value;
updateRow(row.key, {
selectedBagCodes:
typeof value === "string" ? value.split(",") : value,
});
}}
renderValue={(selected) => {
const codes = selected as string[];
if (codes.length === 0) {
return (
<Typography variant="body2" color="text.secondary" component="span">
{t("Packaging bags")}
</Typography>
);
}
return (
<Box
component="span"
sx={{
display: "block",
width: "100%",
maxWidth: "100%",
overflow: "hidden",
}}
>
{codes.map((code) => {
const bag = bagItems.find((b) => b.code === code);
return (
<Typography
key={code}
variant="body2"
component="div"
sx={{
lineHeight: 1.35,
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
>
{bag ? `${code}-${bag.name}` : code}
</Typography>
);
})}
</Box>
);
}}
>
{bagItems.map((bag) => (
<MenuItem key={bag.code} value={bag.code}>
<Checkbox checked={row.selectedBagCodes.includes(bag.code)} />
<ListItemText
primary={`${bag.code} — ${bag.name}`}
primaryTypographyProps={{
sx: { whiteSpace: "normal", wordBreak: "break-word" },
}}
/>
</MenuItem>
))}
</Select>
</FormControl>
);
}
return (
<TextField
size="small"
fullWidth
margin="none"
value={row.description}
onChange={(e) => 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 (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1">{t("Process & Equipment")}</Typography>
{!isEditing ? (
<Button
size="small"
startIcon={<EditIcon />}
variant="outlined"
disabled={masterLoading || comparing}
onClick={() => void startEdit()}
>
{t("Edit Processes")}
</Button>
) : (
<Button size="small" startIcon={<CancelIcon />} variant="outlined" onClick={cancelEdit}>
{t("Cancel")}
</Button>
)}
</Stack>
{masterError && (
<Typography color="error" variant="body2" sx={{ mb: 1 }}>
{masterError}
</Typography>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Sequence")}</TableCell>
<TableCell>{t("Process Name")}</TableCell>
<TableCell sx={{ minWidth: 100 }}>{t("Process Description")}</TableCell>
<TableCell>{t("Byproduct")}</TableCell>
<TableCell>{t("Equipment")}</TableCell>
<TableCell align="right">{t("Duration (Minutes)")}</TableCell>
<TableCell align="right">{t("Prep Time (Minutes)")}</TableCell>
<TableCell align="right">{t("Post Prod Time (Minutes)")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isEditing
? rows.map((row) => (
<TableRow key={row.key}>
<TableCell sx={editCellSx}>{row.seqNo ?? "-"}</TableCell>
<TableCell sx={editCellSx}>
<TextField
select
size="small"
margin="none"
fullWidth
value={row.processCode}
onChange={(e) =>
updateRow(row.key, { processCode: String(e.target.value) })
}
sx={editFieldSx}
>
<MenuItem value="">
<em>{t("Select process")}</em>
</MenuItem>
{processList.map((p) => (
<MenuItem key={p.code} value={p.code}>
{formatProcessLabel(p)}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell sx={{ ...editCellSx, minWidth: 220, maxWidth: 360 }}>
{renderDescriptionCell(row)}
</TableCell>
<TableCell sx={editCellSx}>
<TextField
size="small"
margin="none"
placeholder={t("Item code")}
value={row.byProduct}
error={Boolean(byProductErrors[row.key])}
helperText={byProductErrors[row.key]}
onChange={(e) => {
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}
/>
</TableCell>
<TableCell sx={editCellSx}>{renderEquipmentCell(row)}</TableCell>
<TableCell align="right" sx={editCellSx}>
<TextField
size="small"
margin="none"
type="number"
value={row.durationInMinute}
onChange={(e) =>
updateRow(row.key, {
durationInMinute: Number(e.target.value),
})
}
sx={editFieldSx}
/>
</TableCell>
<TableCell align="right" sx={editCellSx}>
<TextField
size="small"
margin="none"
type="number"
value={row.prepTimeInMinute}
onChange={(e) =>
updateRow(row.key, {
prepTimeInMinute: Number(e.target.value),
})
}
sx={editFieldSx}
/>
</TableCell>
<TableCell align="right" sx={editCellSx}>
<TextField
size="small"
margin="none"
type="number"
value={row.postProdTimeInMinute}
onChange={(e) =>
updateRow(row.key, {
postProdTimeInMinute: Number(e.target.value),
})
}
sx={editFieldSx}
/>
</TableCell>
</TableRow>
))
: (detail.processes ?? []).map((p, i) => {
const oldP =
p.seqNo != null ? oldProcessMap.get(Number(p.seqNo)) : undefined;
return (
<TableRow key={p.id ?? i}>
<TableCell>{p.seqNo}</TableCell>
<TableCell>
{processDiffCell(oldP, p, formatProcessNameDisplay, comparing)}
</TableCell>
<TableCell
sx={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxWidth: 360,
verticalAlign: "top",
}}
>
{processDiffCell(oldP, p, formatDescriptionForDto, comparing)}
</TableCell>
<TableCell>
{processDiffCell(
oldP,
p,
(row) => row.byProduct?.trim() || "-",
comparing,
)}
</TableCell>
<TableCell>
{processDiffCell(oldP, p, formatEquipmentDisplay, comparing)}
</TableCell>
<TableCell align="right">
{processDiffCell(
oldP,
p,
(row) => formatMinute(row.durationInMinute),
comparing,
)}
</TableCell>
<TableCell align="right">
{processDiffCell(
oldP,
p,
(row) => formatMinute(row.prepTimeInMinute),
comparing,
)}
</TableCell>
<TableCell align="right">
{processDiffCell(
oldP,
p,
(row) => formatMinute(row.postProdTimeInMinute),
comparing,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Paper>
);
},
);

+ 61
- 0
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) => (
<FormControl size="small" sx={{ width }} disabled={disabled}>
<InputLabel shrink htmlFor={`putaway-${key}`}>
{label}
</InputLabel>
<OutlinedInput
id={`putaway-${key}`}
notched
label={label}
value={value[key]}
onChange={(e) => onChange({ ...value, [key]: e.target.value })}
/>
</FormControl>
);

return (
<Grid container spacing={1} alignItems="flex-end">
<Grid item>
{field("floor", t("Putaway Floor"), 88)}
</Grid>
<Grid item>
{field("warehouse", t("Putaway Warehouse"), 88)}
</Grid>
<Grid item>
{field("area", t("Putaway Area"), 88)}
</Grid>
<Grid item>
{field("slot", t("Putaway Slot"), 88)}
</Grid>
{error ? (
<Grid item xs={12}>
<Typography variant="caption" color="error">
{error}
</Typography>
</Grid>
) : null}
</Grid>
);
}

+ 234
- 0
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 (
<Stack direction="row" spacing={1} alignItems="center">
{dotVariant && <Box sx={versionColorDotSx(dotVariant)} />}
<span>{versionLabel(v)}</span>
</Stack>
);
}

/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-16 */
export const BomVersionControlBar: React.FC<Props> = ({
bomCode,
bomKind,
versionId,
compareMode,
compareToId,
saving,
versionsRefreshKey = 0,
saveDisabled,
saveError,
onVersionChange,
onToggleCompare,
onCompareToChange,
onSave,
}) => {
const { t } = useTranslation("importBom");
const [versions, setVersions] = useState<BomVersionSummary[]>([]);
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 (
<Stack direction="row" spacing={1} alignItems="center">
{dotVariant && <Box sx={versionColorDotSx(dotVariant)} />}
<span>{versionLabel(v)}</span>
</Stack>
);
};

return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "center" }}
spacing={2}
>
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
<FormControl size="small" sx={{ minWidth: 200 }} disabled={controlsDisabled}>
<InputLabel>{t("Version")}</InputLabel>
<Select
label={t("Version")}
value={versionId}
onChange={(e) => onVersionChange(Number(e.target.value))}
renderValue={
comparingTwo
? (val) => renderVersionValue(val as number, "new")
: undefined
}
>
{versions.map((v) => (
<MenuItem key={v.id} value={v.id}>
<VersionMenuItem
v={v}
dotVariant={comparingTwo && v.id === versionId ? "new" : undefined}
/>
</MenuItem>
))}
</Select>
</FormControl>

{showCompareButton && (
<Button
size="small"
variant={compareMode ? "contained" : "outlined"}
onClick={onToggleCompare}
disabled={controlsDisabled}
>
{compareMode ? t("End Compare") : t("Version Compare")}
</Button>
)}

{compareMode && showCompareButton && (
<FormControl size="small" sx={{ minWidth: 200 }} disabled={controlsDisabled}>
<InputLabel>{t("Compare Version")}</InputLabel>
<Select
label={t("Compare Version")}
value={compareToId}
onChange={(e) => onCompareToChange(Number(e.target.value))}
renderValue={
comparingTwo
? (val) => renderVersionValue(val as number, "old")
: undefined
}
>
{versions.map((v) => (
<MenuItem key={v.id} value={v.id}>
<VersionMenuItem
v={v}
dotVariant={
comparingTwo && v.id === compareToId ? "old" : undefined
}
/>
</MenuItem>
))}
</Select>
</FormControl>
)}

{loadingVersions && <CircularProgress size={20} />}
</Stack>

<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Button
size="small"
variant="contained"
startIcon={
saving ? (
<CircularProgress size={16} color="inherit" />
) : (
<SaveIcon />
)
}
disabled={saving || saveDisabled}
onClick={onSave}
>
{saving ? t("Saving...") : t("Save")}
</Button>
</Stack>
</Stack>

{comparingTwo && currentVersion && compareVersion && (
<Stack direction="row" spacing={2} sx={{ mt: 1 }} flexWrap="wrap">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={versionColorDotSx("old")} />
<Typography variant="caption" color="text.secondary">
{t("Compare Version")}: V{compareVersion.revisionNo}
</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={versionColorDotSx("new")} />
<Typography variant="caption" color="text.secondary">
{t("Version")}: V{currentVersion.revisionNo}
</Typography>
</Stack>
</Stack>
)}

{saveError && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
{saveError}
</Typography>
)}
</Paper>
);
};

+ 376
- 752
src/components/ImportBom/ImportBomDetailTab.tsx
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 542
- 145
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<T>(
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<CorrectItem[]>(() =>
correctFileNames.map((fileName) => ({
const [importItems, setImportItems] = useState<ImportableItem[]>(() =>
importableCorrectNames.map((fileName) => ({
fileName,
isAlsoWip: false,
isDrink: false,
isPowderMixture: fileName.includes("箱料粉"),
}))
productType: "other",
})),
);
const [issueItems, setIssueItems] = useState<IssueItem[]>(() =>
importableFailList.map((f) => ({
fileName: f.fileName,
isAlsoWip: false,
productType: "other",
problems: f.problems,
})),
);
const [reviewTarget, setReviewTarget] = useState<ReviewTarget | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState<string | null>(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<string, ImportableItem>();
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 (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap">
{onBack && (
<Button variant="outlined" onClick={onBack}>
返回重選檔案
{t("Back to reselect files")}
</Button>
)}
<Stack direction="column" spacing={0.5}>
<Typography variant="body2" color="text.secondary">
已上傳 {uploadedCount} 個檔案,檢查結果共 {totalChecked} 筆:正確 {correctFileNames.length} 個、失敗 {failList.length} 個
{t("Upload check summary", {
uploaded: uploadedCount,
total: totalChecked,
correct: importItems.length,
failed: issueItems.length,
})}
</Typography>
{uploadedCount !== totalChecked && (
<Typography variant="caption" color="warning.main">
上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。
{t("Upload count mismatch hint")}
</Typography>
)}
</Stack>
@@ -151,12 +410,20 @@ type Props = {
}}
>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom>
{t("Correct BOM List (Can Import)")}
</Typography>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle1">
{t("Correct BOM List (Can Import)")}
</Typography>
{previewLoading && <CircularProgress size={18} />}
</Stack>
{previewError && (
<Typography variant="body2" color="error" sx={{ mb: 1 }}>
{previewError}
</Typography>
)}
<TextField
size="small"
placeholder="搜索檔名"
placeholder={t("Search code or name")}
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
@@ -168,112 +435,242 @@ type Props = {
}}
sx={{ mb: 2, width: "100%" }}
/>
<Stack spacing={0.5}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 0.5, pb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("WIP")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("Drink")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 56 }}>{t("Powder_Mixture")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flex: 1 }}>{t("File Name")}</Typography>
</Stack>
{filteredCorrect.map((item) => (
<Stack
key={item.fileName}
direction="row"
alignItems="center"
spacing={1}
>
<Checkbox
checked={item.isAlsoWip}
onChange={() => handleToggleWip(item.fileName)}
size="small"
/>
<Checkbox
checked={item.isDrink}
onChange={() => handleToggleDrink(item.fileName)}
size="small"
/>
<Checkbox
checked={item.isPowderMixture}
onChange={() => handleTogglePowderMixture(item.fileName)}
size="small"
/>
<Typography
variant="body2"
sx={{ flex: 1 }}
noWrap
>
{item.fileName}
</Typography>
</Stack>
))}
</Stack>
<Box sx={{ overflowX: "auto" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox" />
<TableCell sx={{ minWidth: 100 }}>{t("Bom Kind Mode")}</TableCell>
<TableCell sx={{ minWidth: 120 }}>{t("Product Type")}</TableCell>
<TableCell>{t("Code")}</TableCell>
<TableCell>{t("Name")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredImport.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<Typography variant="body2" color="text.secondary">
{t("None")}
</Typography>
</TableCell>
</TableRow>
) : (
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 (
<TableRow key={item.fileName} hover>
<TableCell padding="checkbox">
<Tooltip title={t("Review import data")}>
<IconButton
size="small"
onClick={() =>
setReviewTarget({
fileName: item.fileName,
mode: "import",
})
}
color={hasEdits ? "primary" : "default"}
>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell>
<TextField
select
size="small"
value={bomKindMode}
onChange={(e) =>
handleBomKindModeChange(
item.fileName,
e.target.value as BomImportKindMode,
)
}
sx={{ minWidth: 96 }}
>
{BOM_KIND_MODE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{bomKindModeLabel(t, opt)}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>
<TextField
select
size="small"
value={item.productType}
onChange={(e) =>
handleProductTypeChange(
item.fileName,
e.target.value as BomProductType,
)
}
sx={{ minWidth: 110 }}
>
{PRODUCT_TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{productTypeLabel(t, opt)}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>{code}</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={name}>
{name}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</Box>
</Paper>

<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom>
{t("Issue BOM List")}
</Typography>
{failList.length === 0 ? (
{issueItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("None")}
</Typography>
) : (
failList.map((f) => (
<Accordion key={f.fileName} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">
{f.fileName}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack component="ul" sx={{ pl: 2, m: 0 }}>
{f.problems.map((p, i) => (
<Typography
key={i}
component="li"
variant="body2"
color="error"
>
{p}
issueItems.map((item) => {
const { code, name } = displayCodeName(item.preview, item.overrides);
const hasEdits = Boolean(item.overrides);
return (
<Accordion key={item.fileName} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ width: "100%", pr: 1 }}
>
<Typography variant="body2" sx={{ flex: 1 }} noWrap>
{code} — {name}
</Typography>
))}
</Stack>
</AccordionDetails>
</Accordion>
))
<Chip
size="small"
color="error"
label={t("Issue count", {
count: item.problems.length,
})}
/>
<Tooltip title={t("Review and fix")}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setReviewTarget({
fileName: item.fileName,
mode: "issue",
});
}}
color={hasEdits ? "primary" : "default"}
>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</AccordionSummary>
<AccordionDetails>
<Stack component="ul" sx={{ pl: 2, m: 0 }}>
{item.problems.map((p, i) => (
<Typography
key={i}
component="li"
variant="body2"
color="error"
>
{p}
</Typography>
))}
</Stack>
</AccordionDetails>
</Accordion>
);
})
)}
</Paper>
</Box>

<BomImportPreviewDialog
open={reviewTarget != null}
batchId={batchId}
fileName={reviewTarget?.fileName}
preview={reviewItem?.preview}
formatProblems={
reviewTarget?.mode === "issue"
? (reviewItem as IssueItem | undefined)?.problems
: undefined
}
initialIsAlsoWip={reviewItem?.isAlsoWip ?? false}
initialProductType={reviewItem?.productType ?? "other"}
initialOverrides={reviewItem?.overrides}
closeOnSave={reviewTarget?.mode !== "issue"}
onClose={() => 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,
),
);
}}
/>

<Stack direction="row" alignItems="center" spacing={2}>
<Button
variant="contained"
onClick={handleConfirm}
disabled={submitting || items.length === 0}
disabled={submitting || previewLoading || revalidating || importItems.length === 0}
>
確認匯入
{t("Confirm import")}
</Button>
<Button
variant="outlined"
onClick={handleDownloadIssueLog}
disabled={!issueLogFileId}
variant="outlined"
onClick={handleDownloadIssueLog}
disabled={!issueLogFileId || downloadingLog}
>
下載檢查結果 Excel
{t("Download issue log")}
</Button>
{submitting && <CircularProgress size={24} />}
{(submitting || revalidating) && <CircularProgress size={24} />}
{successMsg && (
<Typography variant="body2" color="primary">
{successMsg}
</Typography>
)}
</Stack>
{items.length > 0 && (
{importItems.length > 0 && (
<Typography variant="caption" color="text.secondary">
將匯入 {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 }) : ""}
</Typography>
)}
</Stack>


+ 1
- 0
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<File[]>([]);
const [loading, setLoading] = useState(false);


+ 501
- 0
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<BomScaleField, readonly number[]> = {
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<BomScaleField, string> = {
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;
}

+ 117
- 0
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 (
<>
<Box component="span" sx={compareOldSx}>
{o}
</Box>
<Box component="span" sx={compareNewSx}>
{n}
</Box>
</>
);
}

export function DiffValue({
comparing,
oldVal,
newVal,
}: {
comparing: boolean;
oldVal: string;
newVal: string;
}) {
if (!comparing) {
return <>{newVal}</>;
}
return <InlineDiff oldVal={oldVal} newVal={newVal} />;
}

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<T>(
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 (
<Box component="span" sx={compareNewSx}>
{newStr}
</Box>
);
}
if (o && !n) {
return (
<Box component="span" sx={compareOldSx}>
{oldStr}
</Box>
);
}
return <InlineDiff oldVal={oldStr} newVal={newStr} />;
}

export function buildOldMaterialMap(
compareOldDetail: BomDetailResponse | null,
): Map<string, BomMaterialDto> {
if (!compareOldDetail) return new Map();
return new Map(compareOldDetail.materials.map((m) => [m.itemCode ?? "", m]));
}

export function buildOldProcessMap(
compareOldDetail: BomDetailResponse | null,
): Map<number, BomProcessDto> {
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);
}

+ 205
- 0
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<Record<ProcessFormatFieldKey, string[]>>;

export type MaterialRowFormatErrors = Partial<Record<MaterialFormatFieldKey, string[]>>;

export type MappedFormatProblems = {
basic: Partial<Record<BasicFormatFieldKey, string[]>>;
materialByRowKey: Record<string, MaterialRowFormatErrors>;
processByRowKey: Record<string, ProcessRowFormatErrors>;
hasMaterialIssues: boolean;
hasProcessIssues: boolean;
};

function pushMessage<T extends string>(
map: Partial<Record<T, string[]>>,
key: T,
message: string,
): Partial<Record<T, string[]>> {
const existing = map[key] ?? [];
if (existing.includes(message)) return map;
return { ...map, [key]: [...existing, message] };
}

function pushProcessMessage(
map: Record<string, ProcessRowFormatErrors>,
rowKey: string,
field: ProcessFormatFieldKey,
message: string,
): Record<string, ProcessRowFormatErrors> {
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<string, MaterialRowFormatErrors>,
rowKey: string,
field: MaterialFormatFieldKey,
message: string,
): Record<string, MaterialRowFormatErrors> {
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<Record<BasicFormatFieldKey, string[]>> = {};
let materialByRowKey: Record<string, MaterialRowFormatErrors> = {};
let processByRowKey: Record<string, ProcessRowFormatErrors> = {};
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);
}

+ 34
- 0
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");
}

+ 377
- 0
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, unknown>) => string,
rows: ProcessEditRow[],
processList: ProcessMasterRow[],
equipmentList: EquipmentMasterRow[],
bagItems: BagItemCombo[],
byProductErrors: Record<string, string>,
): 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, unknown>) => string,
rows: ImportMaterialEditRow[],
): Promise<string | null> {
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<string | null>,
): Promise<string | null> {
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 };

+ 115
- 0
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<string>();
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 };
}

+ 43
- 0
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;
}

+ 100
- 0
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,
};
}

+ 46
- 0
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<string, BomCombo[]>();
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;
}

+ 52
- 0
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>,
): 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;
}

+ 11
- 3
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<number, number>;
jobTypes: JobTypeResponse[];
defaultPlanStart: string;
rememberPlanStart: boolean;
@@ -28,6 +29,7 @@ interface Props {
const JoCreateFormModal: React.FC<Props> = ({
open,
bomCombo,
bomIssueCountByBomId = {},
jobTypes,
defaultPlanStart,
rememberPlanStart,
@@ -54,6 +56,7 @@ const JoCreateFormModal: React.FC<Props> = ({

// 监听 bomId 变化
const selectedBomId = watch("bomId");
const selectedBomIssueCount = selectedBomId ? (bomIssueCountByBomId[selectedBomId] ?? 0) : 0;
/*
const handleAutoCompleteChange = useCallback(
(event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => {
@@ -282,6 +285,11 @@ const JoCreateFormModal: React.FC<Props> = ({
/>
)}
/>
{selectedBomIssueCount > 0 ? (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("joCreate_bom_issue_warning", { count: selectedBomIssueCount })}
</Alert>
) : null}
</Grid>
<Grid item xs={12} sm={12} md={6}>
<Controller
@@ -292,7 +300,7 @@ const JoCreateFormModal: React.FC<Props> = ({
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<Props> = ({
variant="contained"
startIcon={isSubmitting ? <CircularProgress size={16} color="inherit" /> : <Check />}
type="submit"
disabled={isSubmitting}
disabled={isSubmitting|| selectedBomIssueCount > 0}
>
{isSubmitting ? t("Creating...") : t("Create")}
</Button>


+ 9
- 1
src/components/JoSearch/JoSearch.tsx 파일 보기

@@ -36,13 +36,20 @@ import { useJoCreatePlanStartPrefs } from "@/hooks/useJoCreatePlanStartPrefs";
interface Props {
defaultInputs: SearchJoResultRequest,
bomCombo: BomCombo[]
bomIssueCountByBomId: Record<number, number>;
printerCombo: PrinterCombo[];
jobTypes: JobTypeResponse[];
}

type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus";

const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobTypes }) => {
const JoSearch: React.FC<Props> = ({
defaultInputs,
bomCombo,
bomIssueCountByBomId,
printerCombo,
jobTypes,
}) => {
const { t } = useTranslation("jo");
const router = useRouter()
const [filteredJos, setFilteredJos] = useState<JobOrder[]>([]);
@@ -767,6 +774,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
<JoCreateFormModal
open={isCreateJoModalOpen}
bomCombo={bomCombo}
bomIssueCountByBomId={bomIssueCountByBomId}
jobTypes={jobTypes}
defaultPlanStart={defaultPlanStartForCreate}
rememberPlanStart={rememberPlanStart}


+ 18
- 2
src/components/JoSearch/JoSearchWrapper.tsx 파일 보기

@@ -2,7 +2,7 @@ import React from "react";
import GeneralLoading from "../General/GeneralLoading";
import JoSearch from "./JoSearch";
import { SearchJoResultRequest } from "@/app/api/jo/actions";
import { fetchBomCombo } from "@/app/api/bom";
import { fetchBomCombo, fetchBomComboIssues } from "@/app/api/bom";
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { fetchAllJobTypes } from "@/app/api/jo/actions";
interface SubComponents {
@@ -24,15 +24,31 @@ const JoSearchWrapper: React.FC & SubComponents = async () => {

const [
bomCombo,
bomComboIssues,
printerCombo,
jobTypes
] = await Promise.all([
fetchBomCombo(),
fetchBomComboIssues(),
fetchPrinterCombo(),
fetchAllJobTypes()
])

const bomIssueCountByBomId = bomComboIssues.reduce<Record<number, number>>((acc, issue) => {
const id = issue.bomId;
acc[id] = (acc[id] ?? 0) + 1;
return acc;
}, {});
return <JoSearch defaultInputs={defaultInputs} bomCombo={bomCombo} printerCombo={printerCombo} jobTypes={jobTypes}/>
return (
<JoSearch
defaultInputs={defaultInputs}
bomCombo={bomCombo}
bomIssueCountByBomId={bomIssueCountByBomId}
printerCombo={printerCombo}
jobTypes={jobTypes}
/>
);
}

JoSearchWrapper.Loading = GeneralLoading;


+ 9
- 1
src/components/JoWorkbench/JoWorkbenchSearch.tsx 파일 보기

@@ -37,13 +37,20 @@ import { useJoCreatePlanStartPrefs } from "@/hooks/useJoCreatePlanStartPrefs";
interface Props {
defaultInputs: SearchJoResultRequest,
bomCombo: BomCombo[]
bomIssueCountByBomId?: Record<number, number>;
printerCombo: PrinterCombo[];
jobTypes: JobTypeResponse[];
}

type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus";

const JoWorkbenchSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobTypes = [] }) => {
const JoWorkbenchSearch: React.FC<Props> = ({
defaultInputs,
bomCombo,
bomIssueCountByBomId = {},
printerCombo,
jobTypes = [],
}) => {
const { t } = useTranslation("jo");
const router = useRouter()
const [filteredJos, setFilteredJos] = useState<JobOrder[]>([]);
@@ -768,6 +775,7 @@ const JoWorkbenchSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCo
<JoCreateFormModal
open={isCreateJoModalOpen}
bomCombo={bomCombo}
bomIssueCountByBomId={bomIssueCountByBomId}
jobTypes={jobTypes}
defaultPlanStart={defaultPlanStartForCreate}
rememberPlanStart={rememberPlanStart}


+ 808
- 0
src/components/MasterDataIssues/BomUomAlignmentDialog.tsx 파일 보기

@@ -0,0 +1,808 @@
"use client";

import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
MenuItem,
Select,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import type {
BomHeaderUomAlignmentPreview,
BomMaterialUomAlignmentPreview,
BomMaterialUomOption,
BomUomAlignmentPreviewResponse,
BomUomLabel,
BomUomQtyValue,
} from "@/app/api/masterDataIssues/bomUomAlignment";
import {
applyBomUomAlignmentClient,
previewBomUomAlignmentClient,
recalculateBomHeaderOutputQtyClient,
recalculateBomMaterialQtyClient,
} from "@/app/api/masterDataIssues/client";

interface Props {
open: boolean;
onClose: () => 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<string, string> = {
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<Props> = ({
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<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [preview, setPreview] = useState<BomUomAlignmentPreviewResponse | null>(null);
const [headerEdits, setHeaderEdits] = useState<Record<number, HeaderEdit>>({});
const [materialEdits, setMaterialEdits] = useState<Record<number, MaterialEdit>>({});
const recalcTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRecalcTimerRef = useRef<ReturnType<typeof setTimeout> | 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<number, HeaderEdit> = {};
const headerRecalcTasks: Array<Promise<void>> = [];
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<number, MaterialEdit> = {};
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(
<Alert key="1to1" severity="warning">
{t("masterDataIssue_align_warn_1to1")}
</Alert>,
);
}
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(
<Alert key="nullQty" severity="info">
{t("masterDataIssue_align_warn_bom_qty_null", {
fields: nullFieldLabels.join("、"),
})}
</Alert>,
);
}
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 (
<TableRow>
<TableCell>{t("masterDataIssue_align_recipeQty")}</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatBeforeQty(m.beforeRecipeQty, t("masterDataIssue_align_not_set"))}
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
type="number"
value={edit.recipeQty}
onChange={(e) => updateMaterialQty(m.bomMaterialId, e.target.value)}
inputProps={{ step: "any", min: 0 }}
sx={alignQtyInputSx}
/>
{edit.availableRecipeUoms.length <= 1 ? (
<Typography variant="body2" sx={alignUomLabelSx}>
{recipeUomLabel}
</Typography>
) : (
<FormControl size="small" sx={alignUomSelectSx}>
<Select
value={edit.recipeUomId}
onChange={(e) =>
updateMaterialUom(m.bomMaterialId, Number(e.target.value))
}
>
{edit.availableRecipeUoms.map((u) => (
<MenuItem key={u.uomId} value={u.uomId}>
{u.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Stack>
</TableCell>
</TableRow>
);
};

const renderReadonlyQtyRow = (
label: string,
before: BomUomQtyValue | null | undefined,
afterQty: string,
afterUom: BomUomLabel | null | undefined,
) => (
<TableRow>
<TableCell>{label}</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatBeforeQty(before, t("masterDataIssue_align_not_set"))}
</TableCell>
<TableCell>
<Typography variant="body2" color="success.dark">
{afterQty} {uomLabel(afterUom)}
</Typography>
</TableCell>
</TableRow>
);

const renderHeaderCard = (h: BomHeaderUomAlignmentPreview) => (
<Box key={h.bomId} sx={{ mb: 2, p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{h.bomCode} · {h.bomName}
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("masterDataIssue_align_field")}</TableCell>
<TableCell>{t("masterDataIssue_col_bom_uom")}</TableCell>
<TableCell>{t("masterDataIssue_align_after")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
{t("masterDataIssue_align_outputStock")}
</TableCell>
<TableCell sx={{ color: "error.main" }}>
{h.beforeOutputQtyStock == null && h.beforeStockUom == null
? "—"
: formatHeaderBeforeQty(
h.beforeOutputQtyStock,
h.beforeStockUom,
t("masterDataIssue_align_not_set"),
)}
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
type="number"
value={headerEdits[h.bomId]?.outputQtyStock ?? ""}
onChange={(e) => updateHeaderStockQty(h.bomId, e.target.value, h)}
inputProps={{ step: "any", min: 0 }}
sx={alignQtyInputSx}
/>
<Typography variant="body2" sx={alignUomLabelSx}>
{uomLabel(h.afterStockUom)}
</Typography>
</Stack>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
{t("masterDataIssue_align_outputBase")}
<Typography variant="caption" color="text.secondary" display="block">
{t("masterDataIssue_align_reference")}
</Typography>
</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatHeaderBeforeQty(
h.beforeOutputQty,
h.beforeBaseUom ?? h.beforeUom,
t("masterDataIssue_align_not_set"),
)}
</TableCell>
<TableCell sx={{ color: "text.secondary" }}>
{formatHeaderBeforeQty(
headerEdits[h.bomId]?.outputQty || h.afterOutputQty,
h.afterBaseUom ?? h.afterUom,
t("masterDataIssue_align_not_set"),
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
);

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) => (
<Box
key={m.bomMaterialId}
sx={{ mb: 2, p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}
>
<Typography variant="subtitle2">
{m.itemCode} · {m.itemName}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{t("masterDataIssue_usedInBom", {
count: 1,
codes: `${m.bomCode ?? ""} · ${m.bomName ?? ""}`,
})}
</Typography>
{m.skipReason ? (
<Alert severity="error" sx={{ mb: 1 }}>
{m.skipReason}
</Alert>
) : null}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("masterDataIssue_align_field")}</TableCell>
<TableCell>{t("masterDataIssue_col_bom_uom")}</TableCell>
<TableCell>{t("masterDataIssue_align_after")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>{t("masterDataIssue_align_recipeQty")}</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatBeforeQty(m.beforeRecipeQty, t("masterDataIssue_align_not_set"))}
</TableCell>
<TableCell sx={{ color: "text.secondary" }}>
{renderReadonlyAfterQty(m.afterRecipeQty)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("masterDataIssue_unit_base")}</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatBeforeQty(m.beforeBaseQty, t("masterDataIssue_align_not_set"))}
</TableCell>
<TableCell sx={{ color: "text.secondary" }}>
{renderReadonlyAfterQty(m.afterBaseQty)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("masterDataIssue_unit_stock")}</TableCell>
<TableCell sx={{ color: "error.main" }}>
{formatBeforeQty(m.beforeStockQty, t("masterDataIssue_align_not_set"))}
</TableCell>
<TableCell sx={{ color: "text.secondary" }}>
{renderReadonlyAfterQty(m.afterStockQty)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
);

const renderMaterialCard = (m: BomMaterialUomAlignmentPreview) => {
const edit = materialEdits[m.bomMaterialId];
if (!edit) return null;
const materialWarnings = renderMaterialWarnings(m);
return (
<Box
key={m.bomMaterialId}
sx={{ mb: 2, p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}
>
<Typography variant="subtitle2">
{m.itemCode} · {m.itemName}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{t("masterDataIssue_usedInBom", {
count: 1,
codes: `${m.bomCode ?? ""} · ${m.bomName ?? ""}`,
})}
</Typography>
{materialWarnings.length > 0 ? (
<Stack spacing={1} sx={{ mb: 1 }}>
{materialWarnings}
</Stack>
) : null}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("masterDataIssue_align_field")}</TableCell>
<TableCell>{t("masterDataIssue_col_bom_uom")}</TableCell>
<TableCell>{t("masterDataIssue_align_after")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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,
)}
</TableBody>
</Table>
</Box>
);
};

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t("masterDataIssue_align_title")}</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Alert severity="info">{t("masterDataIssue_align_info_m18")}</Alert>
<Alert severity="warning">{t("masterDataIssue_align_info_excel")}</Alert>

{error ? <Alert severity="error">{error}</Alert> : null}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={32} />
</Box>
) : preview ? (
<>
<Typography variant="body2" color="text.secondary">
{t("masterDataIssue_align_summary", {
headers: applicableHeaders.length,
materials: applicableMaterials.length,
skipped: skippedCount,
})}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
<Tab label={t("masterDataIssue_align_tab_headers", { count: applicableHeaders.length })} />
<Tab label={t("masterDataIssue_align_tab_materials", { count: applicableMaterials.length })} />
{skippedCount > 0 ? (
<Tab label={t("masterDataIssue_align_tab_skipped", { count: skippedCount })} />
) : null}
</Tabs>
{tab === 0 &&
(applicableHeaders.length > 0 ? (
applicableHeaders.map(renderHeaderCard)
) : (
<Typography variant="body2" color="text.secondary">
{t("masterDataIssue_align_none_headers")}
</Typography>
))}
{tab === 1 &&
(applicableMaterials.length > 0 ? (
applicableMaterials.map(renderMaterialCard)
) : (
<Typography variant="body2" color="text.secondary">
{t("masterDataIssue_align_none_materials")}
</Typography>
))}
{tab === 2 && skippedCount > 0 ? (
<Stack spacing={2}>
{blockedMaterials.map(renderBlockedMaterialCard)}
{preview.skipped.map((s, i) => (
<Typography key={`skipped-${i}`} variant="body2">
{[s.bomCode, s.itemCode].filter(Boolean).join(" / ")} — {s.reason}
</Typography>
))}
</Stack>
) : null}
</>
) : null}

<FormControlLabel
control={
<Checkbox checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
}
label={t("masterDataIssue_align_confirm")}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={applying}>
{t("masterDataIssue_close")}
</Button>
<Button variant="contained" disabled={!canApply || applying} onClick={() => void handleApply()}>
{applying ? t("masterDataIssue_align_applying") : t("masterDataIssue_align_apply")}
</Button>
</DialogActions>
</Dialog>
);
};

export default BomUomAlignmentDialog;

+ 111
- 16
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<Props> = ({
onRefresh,
}) => {
const { t } = useTranslation("masterDataIssue");
const [alignScope, setAlignScope] = useState<{
bomIds?: number[];
bomMaterialIds?: number[];
} | null>(null);
const inFlightRef = useRef(false);
const [scopeFilter, setScopeFilter] = useState<string>("ALL");
const [search, setSearch] = useState("");
@@ -245,33 +286,61 @@ const MasterDataIssuesPanel: React.FC<Props> = ({
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<Props> = ({

interface BomCompareRow {
key: string;
unitLabel: string;
bomUnitLabel: string;
itemUnitLabel: string;
bomValue: string;
itemValue: string;
}
@@ -305,7 +375,7 @@ const BomCompareColumn: React.FC<BomCompareColumnProps> = ({
variant="body2"
sx={{ color: labelColor, fontWeight: 600, lineHeight: 1.3 }}
>
{row.unitLabel}
{valueKey === "bomValue" ? row.bomUnitLabel : row.itemUnitLabel}
</Typography>
<Typography
variant="body2"
@@ -425,6 +495,16 @@ const BomCompareColumn: React.FC<BomCompareColumnProps> = ({
</Button>
</Stack>

{mode === "bom" ? (
<BomUomAlignmentDialog
open={alignScope != null}
onClose={() => setAlignScope(null)}
onApplied={() => void onRefresh()}
bomIds={alignScope?.bomIds}
bomMaterialIds={alignScope?.bomMaterialIds}
/>
) : null}

{loadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{loadError}
@@ -461,6 +541,9 @@ const BomCompareColumn: React.FC<BomCompareColumnProps> = ({
<>
<TableCell>{t("masterDataIssue_col_bom_uom", { defaultValue: "BOM UOM" })}</TableCell>
<TableCell>{t("masterDataIssue_col_item_uom", { defaultValue: "Item 正确 UOM" })}</TableCell>
<TableCell sx={{ width: 120 }}>
{t("masterDataIssue_col_actions")}
</TableCell>
</>
) : (
<>
@@ -560,6 +643,18 @@ const BomCompareColumn: React.FC<BomCompareColumnProps> = ({
valueColor="success.dark"
/>
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
{groupCanOpenAlignDialog(group) ? (
<Button
size="small"
variant="contained"
disabled={loading}
onClick={() => setAlignScope(alignScopeFromGroup(group))}
>
{t("masterDataIssue_align_row")}
</Button>
) : null}
</TableCell>
</>
) : (
<>


+ 9
- 7
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<MasterDataIssue[]>([]);
const [itemIssues, setItemIssues] = useState<MasterDataIssue[]>([]);
@@ -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 (
<Box>
@@ -87,7 +89,7 @@ const MasterDataIssuesTabs: React.FC = () => {
<MasterDataIssuesPanel
mode="bom"
issues={bomIssues}
loading={bomLoading}
loading={bomLoading || !isAuthReady}
loadError={bomError}
onRefresh={loadBomIssues}
/>
@@ -95,7 +97,7 @@ const MasterDataIssuesTabs: React.FC = () => {
<MasterDataIssuesPanel
mode="item"
issues={itemIssues}
loading={itemLoading}
loading={itemLoading || !isAuthReady}
loadError={itemError}
onRefresh={loadItemIssues}
/>


+ 34
- 116
src/components/MasterDataIssues/buildDisplayLines.ts 파일 보기

@@ -55,69 +55,40 @@ function groupIssuesByBom(issues: MasterDataIssue[]): Map<string, MasterDataIssu
return map;
}

/** UOM mismatch lines without BOM prefix (for material groups). */
function uomPairLines(
function linesForBomHeaderRows(
bomCode: string,
rows: MasterDataIssue[],
t: TFunction,
issueMessage: (code: string) => 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<MasterDataIssue>();

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<MasterDataIssue>();

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<MasterDataIssue>();
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`,


+ 4
- 7
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}`;
}


+ 55
- 0
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 (
<Box sx={{ width: "100%" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
{title}
</Typography>
<Grid container spacing={1.5}>
{items.map((item) => (
<Grid item xs={6} sm={4} md={3} key={item.labelKey}>
<Paper
variant="outlined"
sx={{
p: 1.5,
height: "100%",
borderRadius: 1,
bgcolor: "background.paper",
}}
>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 0.5, lineHeight: 1.3 }}
>
{tBom(item.labelKey)}
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.4 }}>
{item.value}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Box>
);
}

+ 4
- 9
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) => {
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity")}
fullWidth
disabled={true}
value={`${processData?.isDark == null || processData?.isDark === "" ? t("N/A") : processData.isDark} | ${processData?.isDense == null || processData?.isDense === "" || processData?.isDense === 0 ? t("N/A") : processData.isDense} | ${processData?.isFloat == null || processData?.isFloat === "" ? t("N/A") : processData.isFloat} | ${processData?.scrapRate == -1 || processData?.scrapRate === "" ? t("N/A") : processData.scrapRate} | ${processData?.allergicSubstance == null || processData?.allergicSubstance === "" ? t("N/A") :t (processData.allergicSubstance)} | ${processData?.timeSequence == null || processData?.timeSequence === "" ? t("N/A") : processData.timeSequence} | ${processData?.complexity == null || processData?.complexity === "" ? t("N/A") : processData.complexity}`}
/>
<Grid item xs={12}>
<JobOrderBomAttributesGrid attributes={processData} />
</Grid>

</Grid>


+ 1
- 1
src/components/ScheduleTable/BomMaterialTable.tsx 파일 보기

@@ -166,7 +166,7 @@ function BomMaterialTable({ bomMaterial }: Props) {
newRow: GridRowModel<BomMaterialRow>,
): BomMaterialEntryError | undefined => {
const error: BomMaterialEntryError = {};
console.log(newRow);
//console.log(newRow);
return Object.keys(error).length > 0 ? error : undefined;
},
[],


+ 1
- 1
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<number, { shop: Shop; trucks: ShopAndTruck[] }>();


+ 6
- 4
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 };
}

+ 10
- 1
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."
}

+ 3
- 2
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."
}

+ 25
- 10
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"
}

+ 2
- 1
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",


+ 113
- 7
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 上架位置未設定,請先填寫完整上架位置後再儲存。"

}

+ 12
- 3
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/貨品單位問題」修正。"
}

+ 25
- 10
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": "配方單位"
}

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


불러오는 중...
취소
저장