Ver código fonte

Merge branch 'production' into bomUpdateTest

bomUpdateTest
CANCERYS\kw093 1 dia atrás
pai
commit
d9d66bb2a5
100 arquivos alterados com 20100 adições e 1764 exclusões
  1. +1818
    -481
      package-lock.json
  2. +8
    -3
      package.json
  3. +44
    -0
      src/app/(main)/itemTracing/page.tsx
  4. +15
    -1
      src/app/(main)/report/page.tsx
  5. +1
    -1
      src/app/(main)/report/reportCategories.ts
  6. +161
    -0
      src/app/(main)/report/shopOrderReplenishmentReportApi.ts
  7. +1
    -1
      src/app/(main)/settings/itemPrice/page.tsx
  8. +22
    -4
      src/app/api/doworkbench/actions.ts
  9. +20
    -0
      src/app/api/inventory/actions.ts
  10. +24
    -0
      src/app/api/itemTracing/actions.ts
  11. +496
    -0
      src/app/api/itemTracing/index.ts
  12. +211
    -0
      src/app/api/itemTracing/schema.ts
  13. +80
    -3
      src/app/api/jo/actions.ts
  14. +24
    -0
      src/app/api/settings/item/actions.ts
  15. +3
    -10
      src/app/utils/fetchUtil.ts
  16. +21
    -0
      src/app/utils/serverFetchError.ts
  17. +1
    -0
      src/authorities.ts
  18. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  19. +13
    -3
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  20. +16
    -1
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  21. +59
    -17
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  22. +215
    -55
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  23. +60
    -19
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  24. +86
    -1
      src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
  25. +21
    -7
      src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx
  26. +7
    -6
      src/components/InventorySearch/InventorySearch.tsx
  27. +3
    -3
      src/components/ItemPriceSearch/ItemPriceSearch.tsx
  28. +151
    -0
      src/components/ItemTracing/ItemTracing.tsx
  29. +94
    -0
      src/components/ItemTracing/ItemTracingDocLink.tsx
  30. +797
    -0
      src/components/ItemTracing/ItemTracingFlowGraph.tsx
  31. +149
    -0
      src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
  32. +11
    -0
      src/components/ItemTracing/ItemTracingLoading.tsx
  33. +791
    -0
      src/components/ItemTracing/ItemTracingLocations.tsx
  34. +50
    -0
      src/components/ItemTracing/ItemTracingLotTraceLink.tsx
  35. +265
    -0
      src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
  36. +142
    -0
      src/components/ItemTracing/ItemTracingScanBar.tsx
  37. +910
    -0
      src/components/ItemTracing/ItemTracingSections.tsx
  38. +100
    -0
      src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
  39. +291
    -0
      src/components/ItemTracing/ItemTracingSummary.tsx
  40. +58
    -0
      src/components/ItemTracing/TraceFlowEdge.tsx
  41. +878
    -0
      src/components/ItemTracing/TraceFlowNodes.tsx
  42. +278
    -0
      src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
  43. +632
    -0
      src/components/ItemTracing/buildJoPreludeGraphNodes.ts
  44. +172
    -0
      src/components/ItemTracing/buildLocationBlockGraphNodes.ts
  45. +169
    -0
      src/components/ItemTracing/buildProductionGraphNodes.ts
  46. +323
    -0
      src/components/ItemTracing/buildReactFlowGraph.ts
  47. +1223
    -0
      src/components/ItemTracing/buildTraceGraphNodes.ts
  48. +33
    -0
      src/components/ItemTracing/compileTraceGraph.ts
  49. +905
    -0
      src/components/ItemTracing/exportItemLotTraceXlsx.ts
  50. +5
    -0
      src/components/ItemTracing/index.ts
  51. +113
    -0
      src/components/ItemTracing/itemTracingTableFilters.tsx
  52. +316
    -0
      src/components/ItemTracing/mergeLocationScopedData.ts
  53. +274
    -0
      src/components/ItemTracing/traceDoGroupLayout.ts
  54. +155
    -0
      src/components/ItemTracing/traceDocLinkUtils.ts
  55. +38
    -0
      src/components/ItemTracing/traceFlowConstants.ts
  56. +395
    -0
      src/components/ItemTracing/traceFlowEdgeLayout.ts
  57. +200
    -0
      src/components/ItemTracing/traceFlowLayout.ts
  58. +223
    -0
      src/components/ItemTracing/traceFlowNodeUtils.ts
  59. +291
    -0
      src/components/ItemTracing/traceGraphLabels.ts
  60. +402
    -0
      src/components/ItemTracing/traceGraphLayout.ts
  61. +47
    -0
      src/components/ItemTracing/traceGraphSearch.ts
  62. +1169
    -0
      src/components/ItemTracing/traceGraphSemantics.ts
  63. +192
    -0
      src/components/ItemTracing/traceLabelUtils.ts
  64. +14
    -0
      src/components/ItemTracing/traceNavigationUtils.ts
  65. +213
    -0
      src/components/ItemTracing/traceNodeFactory.ts
  66. +200
    -0
      src/components/ItemTracing/tracePresentationAdapter.ts
  67. +174
    -0
      src/components/ItemTracing/tracePutawayUtils.ts
  68. +38
    -0
      src/components/ItemTracing/traceQtyUtils.ts
  69. +176
    -0
      src/components/ItemTracing/traceStockTakeUtils.ts
  70. +207
    -111
      src/components/JoWorkbench/JoPickOrderList.tsx
  71. +85
    -80
      src/components/JoWorkbench/newJobPickExecution.tsx
  72. +26
    -1
      src/components/Jodetail/JodetailSearch.tsx
  73. +42
    -5
      src/components/Jodetail/completeJobOrderRecord.tsx
  74. +8
    -1
      src/components/NavigationContent/NavigationContent.tsx
  75. +8
    -3
      src/components/PickOrderSearch/AssignAndRelease.tsx
  76. +12
    -13
      src/components/PickOrderSearch/CreatedItemsTable.tsx
  77. +55
    -70
      src/components/PickOrderSearch/SearchResultsTable.tsx
  78. +195
    -76
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  79. +139
    -137
      src/components/PickOrderSearch/newcreatitem.tsx
  80. +350
    -0
      src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx
  81. +750
    -0
      src/components/ProductionProcess/JobOrderOpsTable.tsx
  82. +29
    -52
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  83. +747
    -197
      src/components/ProductionProcess/ProductionProcessList.tsx
  84. +29
    -70
      src/components/ProductionProcess/ProductionProcessPage.tsx
  85. +48
    -1
      src/config/reportConfig.ts
  86. +7
    -125
      src/i18n/en/inventory.json
  87. +321
    -0
      src/i18n/en/itemTracing.json
  88. +2
    -0
      src/i18n/en/jo.json
  89. +2
    -0
      src/i18n/en/navigation.json
  90. +4
    -0
      src/i18n/en/pickOrder.json
  91. +33
    -1
      src/i18n/en/productionProcess.json
  92. +3
    -0
      src/i18n/en/ticketReleaseTable.json
  93. +72
    -190
      src/i18n/zh/inventory.json
  94. +321
    -0
      src/i18n/zh/itemTracing.json
  95. +3
    -0
      src/i18n/zh/jo.json
  96. +2
    -0
      src/i18n/zh/navigation.json
  97. +19
    -14
      src/i18n/zh/pickOrder.json
  98. +33
    -1
      src/i18n/zh/productionProcess.json
  99. +3
    -0
      src/i18n/zh/ticketReleaseTable.json
  100. +57
    -0
      src/utils/traceDoOutboundExtra.ts

+ 1818
- 481
package-lock.json
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 8
- 3
package.json Ver arquivo

@@ -7,7 +7,9 @@
"build": "next build",
"start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
@@ -40,6 +42,7 @@
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@unly/universal-language-detector": "^2.0.3",
"@xyflow/react": "^12.11.2",
"apexcharts": "^3.45.2",
"axios": "^1.9.0",
"dayjs": "^1.11.10",
@@ -67,7 +70,8 @@
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
"xlsx-js-style": "^1.2.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
@@ -85,6 +89,7 @@
"postcss": "^8.4.33",
"prettier": "3.1.1",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.4"
}
}

+ 44
- 0
src/app/(main)/itemTracing/page.tsx Ver arquivo

@@ -0,0 +1,44 @@
import { I18nProvider, getServerI18n } from "@/i18n";
import ItemTracing from "@/components/ItemTracing";
import { AUTH } from "@/authorities";
import { authOptions } from "@/config/authConfig";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import ItemTracingLoading from "@/components/ItemTracing/ItemTracingLoading";

export const metadata: Metadata = {
title: "Item Tracing",
};

const canAccess = (abilities: string[]) =>
abilities.includes(AUTH.ITEM_TRACING);

const ItemTracingPage: React.FC = async () => {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
if (!canAccess(abilities)) {
redirect("/dashboard");
}

const { t } = await getServerI18n("itemTracing");

return (
<>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
{t("title")}
</Typography>
</Stack>
<I18nProvider namespaces={["itemTracing", "navigation", "common"]}>
<Suspense fallback={<ItemTracingLoading />}>
<ItemTracing />
</Suspense>
</I18nProvider>
</>
);
};

export default ItemTracingPage;

+ 15
- 1
src/app/(main)/report/page.tsx Ver arquivo

@@ -31,6 +31,7 @@ import {
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';
import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi';
import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi';
import {
FEATURE_USAGE,
FEATURE_USAGE_ACTION,
@@ -42,6 +43,7 @@ interface ItemCodeWithName {
name: string;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const includeGrnFinancialColumns =
@@ -267,12 +269,24 @@ export default function ReportPage() {
);
} else if (currentReport.id === 'rep-015') {
await generateBomShopSyncReportExcel(criteria, currentReport.title);
} else if (currentReport.id === 'rep-017') {
await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
const queryParams =
let queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: new URLSearchParams(criteria).toString();
// rep-016: single-day UI — mirror dateStart to dateEnd for backend API.
if (currentReport.id === 'rep-016') {
const p = new URLSearchParams(criteria);
const day = (criteria.dateStart || '').trim();
if (day) {
p.set('dateStart', day);
p.set('dateEnd', day);
}
queryParams = p.toString();
}
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;

const response = await clientAuthFetch(excelUrl, {


+ 1
- 1
src/app/(main)/report/reportCategories.ts Ver arquivo

@@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
headerBg: "#b3d4f0",
bodyBg: "#eef5fc",
accent: "#1565c0",
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"],
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"],
},
{
id: "production",


+ 161
- 0
src/app/(main)/report/shopOrderReplenishmentReportApi.ts Ver arquivo

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

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";

export interface ShopOrderReplenishmentReportRow {
shopNo?: string;
shopName?: string;
shopOrderDate?: string;
shopOrderNo?: string;
itemNo?: string;
itemName?: string;
firstOrderQty?: number | string;
firstOrderActualPickQty?: number | string;
reorderQty?: number | string;
reorderDate?: string;
reason?: string;
actualDeliveredQty?: number | string;
deliveredDate?: string;
[key: string]: unknown;
}

export interface ShopOrderReplenishmentReportResponse {
rows: ShopOrderReplenishmentReportRow[];
}

const SHEET_NAME = "店鋪訂單補貨記錄";

const NO_DATA_NOTE =
"(篩選範圍內無資料 / No records in the selected range)";

function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
return {
"Shop No. / 店鋪編號": note,
"Shop Name / 店鋪名稱": "",
"Shop Order Date / 店鋪訂單日期": "",
"Shop Order No. / 店鋪訂單編號": "",
"Item No. / 貨品編號": "",
"Item Name / 貨品名稱": "",
"First Order Qty / 原訂單數量": "",
"First Order Actual Pick Qty / 原單實際提料數量": "",
"Reorder Qty / 補貨數量": "",
"Reorder Date / 補貨日期": "",
"Reason / 補貨原因": "",
"Actual Delivered Qty / 實際補貨數量": "",
"Delivered Date / 送貨日期": "",
};
}

function formatReason(reason: string | undefined): string {
switch ((reason ?? "").trim()) {
case "quality_issue":
return "質素問題";
case "out_of_stock":
return "缺貨";
case "other":
return "其他";
default:
return reason ?? "";
}
}

function formatDateCell(value: unknown): string {
if (value == null || value === "") return "";
if (typeof value === "number" && Number.isFinite(value)) {
// Jackson may serialize java.sql.Date as epoch millis or seconds
const ms = value > 1e12 ? value : value * 1000;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return String(value);
return d.toISOString().slice(0, 10);
}
const s = String(value).trim();
if (/^\d{10,13}$/.test(s)) {
const n = Number(s);
const ms = s.length >= 13 ? n : n * 1000;
const d = new Date(ms);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
}
// JDBC / string date: "2026-08-01" or "2026-08-01 00:00:00.0"
return s.length >= 10 ? s.slice(0, 10) : s;
}

function formatQty(value: unknown): string | number {
if (value === null || value === undefined || value === "") return "";
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return String(value);
return n;
}

function toExcelRow(r: ShopOrderReplenishmentReportRow): Record<string, unknown> {
const base = emptySheetRow("");
return {
...base,
"Shop No. / 店鋪編號": r.shopNo ?? "",
"Shop Name / 店鋪名稱": r.shopName ?? "",
"Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate),
"Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "",
"Item No. / 貨品編號": r.itemNo ?? "",
"Item Name / 貨品名稱": r.itemName ?? "",
"First Order Qty / 原訂單數量": formatQty(r.firstOrderQty),
"First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty),
"Reorder Qty / 補貨數量": formatQty(r.reorderQty),
"Reorder Date / 補貨日期": formatDateCell(r.reorderDate),
"Reason / 補貨原因": formatReason(r.reason),
"Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty),
"Delivered Date / 送貨日期": formatDateCell(r.deliveredDate),
};
}

export async function fetchShopOrderReplenishmentReportData(
criteria: Record<string, string>,
): Promise<ShopOrderReplenishmentReportRow[]> {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment?${queryParams}`;

const response = await clientAuthFetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});

if (response.status === 401 || response.status === 403)
throw new Error("Unauthorized");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const data = (await response.json()) as
| ShopOrderReplenishmentReportResponse
| ShopOrderReplenishmentReportRow[];
if (Array.isArray(data)) return data;
return data.rows ?? [];
}

/**
* Generate and download Shop Orders Replenishment Records as Excel.
*/
export async function generateShopOrderReplenishmentReportExcel(
criteria: Record<string, string>,
reportTitle: string = "店鋪訂單補貨記錄",
): Promise<void> {
const rows = await fetchShopOrderReplenishmentReportData(criteria);
const excelRows =
rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()];

const dateCandidates = [
criteria.reorderDateStart,
criteria.reorderDateEnd,
criteria.shopOrderDateStart,
criteria.shopOrderDateEnd,
criteria.deliveredDateStart,
criteria.deliveredDateEnd,
].filter((v) => (v ?? "").trim().length > 0);
const datePart =
dateCandidates.length > 0
? dateCandidates.join("_")
: new Date().toISOString().slice(0, 10);
const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`;

exportChartToXlsx(excelRows, filename, SHEET_NAME);
}

+ 1
- 1
src/app/(main)/settings/itemPrice/page.tsx Ver arquivo

@@ -15,7 +15,7 @@ const ItemPriceSetting: React.FC = async () => {
<>
<PageTitleBar title={t("Price Inquiry")} className="mb-4" />

<I18nProvider namespaces={["itemPrice","navigation","common","inventory","importExcel"]}>
<I18nProvider namespaces={["itemPrice","navigation","common","importExcel"]}>
<Suspense fallback={<ItemPriceSearch.Loading />}>
<ItemPriceSearch />
</Suspense>


+ 22
- 4
src/app/api/doworkbench/actions.ts Ver arquivo

@@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = {
excludeWarehouseCodes?: string[] | null;
/** Optional decimal string or number serialized by JSON */
qty?: number | string | null;
/** Just Complete button (no QR) — DO user-audit flag */
justComplete?: boolean | null;
userId: number;
};

@@ -144,6 +146,7 @@ export async function workbenchScanPick(
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
...(body.justComplete === true ? { justComplete: true } : {}),
userId: body.userId,
}),
headers: { "Content-Type": "application/json" },
@@ -242,13 +245,16 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
shopName?: string,
storeId?: string,
truck?: string,
releaseType?: string
releaseType?: string,
/** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
floor?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
@@ -261,7 +267,9 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
releaseType?: string
releaseType?: string,
/** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */
floor?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
@@ -269,6 +277,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
if (truck?.trim()) params.append("truck", truck.trim());
if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim());
if (releaseType?.trim()) params.append("releaseType", releaseType.trim());
if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
@@ -417,6 +426,8 @@ export async function fetchWorkbenchCompletedLotDetails(
export type WorkbenchScanPayload = {
itemId: number;
stockInLineId: number;
/** POL UomConversion id — when set, sameItemLots only include matching lot UOM */
uomId?: number | null;
};
export async function fetchWorkbenchPrinters() {
return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, {
@@ -433,9 +444,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) {
});
}

export async function fetchWorkbenchAvailableLotsByItem(itemId: number) {
export async function fetchWorkbenchAvailableLotsByItem(
itemId: number,
uomId?: number | null,
) {
const qs =
uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0
? `?uomId=${Number(uomId)}`
: "";
return serverFetchJson<any>(
`${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`,
`${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`,
{
method: "GET",
cache: "no-store",


+ 20
- 0
src/app/api/inventory/actions.ts Ver arquivo

@@ -23,6 +23,7 @@ export interface LotLineInfo {
lotNo: string;
remainingQty: number;
uom: string;
uomId: number;
}

export interface SearchInventoryLotLine extends Pageable {
@@ -160,13 +161,32 @@ async function fetchInventoriesImpl(data: SearchInventory) {
);
}

async function fetchInventoriesLatestImpl(data: SearchInventory) {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson<InventoryResultByPage>(
`${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`,
{ next: { tags: ["inventories"] } },
);
}

export const fetchInventories = cache(fetchInventoriesImpl);

/**
* FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17
* Inventory search page: latest inventory row per item (no baseUnit/uomId filter).
*/
export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl);

/** Bypass React cache() after mutations so lists show fresh qty. */
export async function fetchInventoriesFresh(data: SearchInventory) {
return fetchInventoriesImpl(data);
}

/** Bypass React cache() for inventory search page latest-inventory search. */
export async function fetchInventoriesLatestFresh(data: SearchInventory) {
return fetchInventoriesLatestImpl(data);
}

async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson<InventoryLotLineResultByPage>(


+ 24
- 0
src/app/api/itemTracing/actions.ts Ver arquivo

@@ -0,0 +1,24 @@
"use server";

import { BASE_API_URL } from "@/config/api";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { ItemLotTraceParams, ItemLotTraceResponse } from ".";
import { parseItemLotTraceResponse } from "./schema";

export const fetchItemLotTrace = async (
params: ItemLotTraceParams,
): Promise<ItemLotTraceResponse> => {
const query = convertObjToURLSearchParams(params as Record<string, unknown>);
const json = await serverFetchJson<unknown>(
`${BASE_API_URL}/inventoryLotLine/trace?${query}`,
{ method: "GET" },
);
return parseItemLotTraceResponse(json) as unknown as ItemLotTraceResponse;
};

/**
* Backend also exposes GET /inventoryLotLine/trace/location/{inventoryLotId} for
* lazy location blocks. The main /trace payload already includes locationBlocks,
* so the UI uses that eager path and does not call a separate location fetch.
*/

+ 496
- 0
src/app/api/itemTracing/index.ts Ver arquivo

@@ -0,0 +1,496 @@
export interface ItemLotTraceLotInfo {
inventoryLotId: number;
lotNo: string;
itemId: number;
itemCode: string;
itemName: string;
expiryDate: string | null;
productionDate: string | null;
stockInDate: string | null;
uom: string;
primaryStockInLineId: number | null;
}

export interface ItemLotTraceWarehouseLine {
inventoryLotLineId: number;
warehouseCode: string;
inQty: number;
outQty: number;
issueQty: number;
status: string;
availableQty: number;
}

export interface ItemLotTraceAlternateLocation {
inventoryLotId: number;
stockInLineId: number | null;
inventoryLotLineId: number;
warehouseCode: string;
availableQty: number;
}

/**
* Per-location lifecycle trace block for one alternate inventory_lot
* sharing the same lotNo+itemId. Contains only location-scoped dimensions.
* Lot-level dimensions (BOM, JO prelude, production, byproducts, lot relations)
* are NOT included — they're at the top level of ItemLotTraceResponse.
*/
export interface ItemLotTraceLocationBlock {
inventoryLotId: number;
lotNo: string;
itemCode: string;
itemName?: string;
uom?: string;
expiryDate?: string | null;
productionDate?: string | null;
stockInDate?: string | null;
stockInLineId: number | null;
warehouseLines: ItemLotTraceWarehouseLine[];
origins: ItemLotTraceOrigin[];
purchaseEvents?: ItemLotTracePurchaseEvent[];
qcResults: ItemLotTraceQcResult[];
putawayEvents: ItemLotTracePutawayEvent[];
movements: ItemLotTraceMovement[];
outboundUsage: ItemLotTraceOutboundUsage[];
stockTakeEvents: ItemLotTraceStockTakeEvent[];
adjustments: ItemLotTraceAdjustment[];
transfers: ItemLotTraceTransfer[];
openMovements: ItemLotTraceOpenMovement[];
failEvents: ItemLotTraceFailEvent[];
doDeliveries: ItemLotTraceDoDelivery[];
replenishmentEvents?: ItemLotTraceReplenishmentEvent[];
returnEvents: ItemLotTraceReturnEvent[];
}

export interface ItemLotTraceReplenishmentEvent {
replenishmentId: number;
replenishmentCode: string;
sourceDoCode: string;
sourceDoId: number | null;
shopCode: string;
shopName: string;
itemNo: string;
itemName: string;
reason: string;
replenishQty: number;
handler: string;
timestamp: string | null;
pickOrderLineId: number | null;
stockOutLineId: number | null;
}

export interface ItemLotTraceOrigin {
stockInLineId: number;
type: string;
refCode: string;
refId: number | null;
supplierCode: string;
supplierName: string;
dnNo: string;
receiptDate: string | null;
acceptedQty: number;
status: string;
}

export interface ItemLotTracePurchaseEvent {
purchaseOrderLineId: number;
purchaseOrderId: number | null;
purchaseOrderCode: string;
itemCode: string;
itemName: string;
orderQty: number;
putAwayQty: number;
purchaseUnit: string;
supplierCode: string;
supplierName: string;
orderDate: string | null;
status: string;
}

export interface ItemLotTraceQcResult {
stockInLineId: number;
qcPassed: boolean;
failQty: number;
acceptedQty: number;
remarks: string;
handledBy: string;
created: string | null;
qcItemCode?: string;
qcItemName?: string;
qcItemDescription?: string;
qcType?: string;
}

export interface ItemLotTraceMovement {
direction: string;
qty: number;
movementType: string;
refType: string;
refCode: string;
refId: number | null;
warehouseCode: string;
handledBy: string;
timestamp: string | null;
remarks: string;
pickConsoCode?: string;
outboundTicketNo?: string;
deliveryOrderPickOrderId?: number | null;
relationshipId?: number | null;
doOutboundIsExtra?: boolean;
doOutboundIsReplenish?: boolean;
doOutboundQtyChanged?: boolean;
processingStatus?: string;
matchStatus?: string;
}

export interface ItemLotTracePutawayEvent {
inventoryLotLineId: number;
stockInLineId: number | null;
warehouseCode: string;
qty: number;
handledBy: string;
timestamp: string | null;
refType: string;
refCode: string;
refId: number | null;
status?: string;
}

export interface ItemLotTraceOutboundUsage {
stockOutLineId: number;
qty: number;
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
jobOrderCode: string;
jobOrderId: number | null;
deliveryOrderCode: string;
handler: string;
timestamp: string | null;
usageType: string;
}

export interface ItemLotTraceStockTakeRecordDetail {
stockTakeLineId: number;
bookQty: number;
pickerFirstQty?: number | null;
pickerFirstBadQty?: number | null;
pickerSecondQty?: number | null;
pickerSecondBadQty?: number | null;
approverQty?: number | null;
approverBadQty?: number | null;
stockTakerName?: string | null;
approverName?: string | null;
stockTakeStartTime?: string | null;
stockTakeEndTime?: string | null;
approverTime?: string | null;
recordStatus?: string | null;
lastSelect?: number | null;
varianceQty?: number | null;
}

export interface ItemLotTraceStockTakeEvent {
stockTakeCode: string;
lotNo?: string;
itemCode?: string;
itemName?: string;
warehouseCode?: string;
stockTakeSection?: string;
stockTakeRoundId?: number | null;
stockTakeRoundName?: string;
varianceQty: number;
beforeQty: number;
afterQty?: number | null;
approver: string;
timestamp: string | null;
recordDetail?: ItemLotTraceStockTakeRecordDetail | null;
}

export interface ItemLotTraceAdjustment {
adjustmentType: string;
direction: string;
qty: number;
reason: string;
handledBy: string;
timestamp: string | null;
refCode: string;
warehouseCode?: string;
}

export interface ItemLotTraceTransfer {
fromWarehouse: string;
toWarehouse: string;
qty: number;
transferCode: string;
timestamp: string | null;
}

export interface ItemLotTraceBomDownstream {
jobOrderCode: string;
jobOrderId: number | null;
finishedItemCode: string;
finishedLotNo: string;
finishedStockInLineId: number | null;
fgQty: number;
fgUom?: string;
materialQtyUsed: number;
}

export interface ItemLotTraceBomUpstream {
jobOrderCode: string;
jobOrderId: number | null;
materialItemCode: string;
materialLotNo: string;
materialStockInLineId: number | null;
materialQty: number;
bomQtyPerUnit: number | null;
}

export interface ItemLotTraceBomRecipeLine {
materialItemCode: string;
materialItemName: string;
qtyPerUnit: number;
uom: string;
bomProcessId?: number | null;
bomProcessSeqNo?: number | null;
assignedStepName?: string;
}

export interface ItemLotTraceStepMaterialRef {
materialItemCode: string;
materialItemName: string;
qtyPerUnit: number;
uom: string;
}

export interface ItemLotTraceBomTrace {
direction: string;
downstream: ItemLotTraceBomDownstream[];
upstream: ItemLotTraceBomUpstream[];
bomRecipe: ItemLotTraceBomRecipeLine[];
}

export interface ItemLotTraceJoContext {
jobOrderId: number;
jobOrderCode: string;
planStart: string | null;
/** Earliest jo_pick_order.created for this JO. */
createdAt?: string | null;
reqQty: number;
status: string;
}

export interface ItemLotTraceJoPickLine {
pickOrderLineId: number;
itemCode: string;
itemName: string;
requiredQty: number;
pickedQty: number;
status: string;
}

export interface ItemLotTraceJoPickOrder {
pickOrderId: number;
pickOrderCode: string;
consoCode: string;
status: string;
targetDate: string | null;
completeDate: string | null;
releasedDate: string | null;
lines: ItemLotTraceJoPickLine[];
}

export interface ItemLotTraceMaterialInput {
jobOrderCode: string;
jobOrderId: number | null;
materialItemCode: string;
materialItemName: string;
materialLotNo: string;
materialStockInLineId: number | null;
materialInventoryLotId: number | null;
materialQty: number;
materialUom?: string;
bomQtyPerUnit: number | null;
bomProcessId?: number | null;
bomProcessSeqNo?: number | null;
assignedStepName?: string;
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
pickedAt: string | null;
processingStatus?: string;
matchStatus?: string;
stockInOrigin: ItemLotTraceOrigin | null;
purchaseEvents?: ItemLotTracePurchaseEvent[];
qcResults: ItemLotTraceQcResult[];
putawayEvents: ItemLotTracePutawayEvent[];
/** When this material was produced by another JO, its upstream pick/prelude chain. */
nestedJoPrelude?: ItemLotTraceJoPrelude | null;
/** Production steps from the JO that produced this material lot. */
productionSteps?: ItemLotTraceProductionStep[];
}

export interface ItemLotTraceJoPrelude {
jobOrder: ItemLotTraceJoContext;
pickOrders: ItemLotTraceJoPickOrder[];
materialInputs: ItemLotTraceMaterialInput[];
}

export interface ItemLotTraceProductionStep {
processLineId: number;
stepName: string;
description: string;
equipmentCode: string;
equipmentName: string;
operatorName: string;
startTime: string | null;
endTime: string | null;
outputQty: number;
scrapQty: number;
defectQty: number;
status: string;
seqNo: number | null;
bomProcessId?: number | null;
stepMaterials?: ItemLotTraceStepMaterialRef[];
}

/** Sibling byproduct lot from the same job order (independent item/lot, traceable). */
export interface ItemLotTraceByproductLot {
inventoryLotId: number | null;
stockInLineId: number | null;
lotNo: string;
itemCode: string;
itemName: string;
qty: number;
uom: string;
processStepName: string;
producedAt: string | null;
jobOrderCode: string;
jobOrderId: number | null;
}

export interface ItemLotTraceOpenMovement {
stockInLineId: number;
qty: number;
warehouseCode: string;
refCode: string;
handledBy: string;
timestamp: string | null;
remarks: string;
}

export interface ItemLotTraceFailEvent {
failId: number;
failType: string;
category: string;
qty: number;
handlerName: string;
recordDate: string | null;
pickOrderCode: string;
pickOrderId: number | null;
}

export interface ItemLotTraceDoDelivery {
stockOutLineId: number;
qty: number;
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
ticketNo?: string;
deliveryOrderCode: string;
deliveryOrderId: number | null;
shopCode?: string;
shopName?: string;
handler: string;
timestamp: string | null;
releaseType?: string | null;
deliveryOrderPickOrderId?: number | null;
relationshipId?: number | null;
deliveryNoteCode?: string;
doPickOrderRecordId?: number | null;
isExtra?: boolean;
isReplenish?: boolean;
/** True when pick_order_line.qty ≠ SUM(stock_out_line.qty) for that line (改數). */
qtyChanged?: boolean;
}

export interface ItemLotTraceReturnEvent {
stockOutLineId: number;
qty: number;
refCode: string;
movementType: string;
warehouseCode: string;
handler: string;
timestamp: string | null;
remarks: string;
}

export interface ItemLotTraceLotRelation {
relationType: string;
inventoryLotId: number | null;
lotNo: string;
itemCode: string;
itemName: string;
qty: number;
productLotNo: string;
timestamp: string | null;
}

export interface ItemLotTraceGraphEdge {
fromKey: string;
toKey: string;
kind?: string;
}

export interface ItemLotTraceGraphNodeRef {
key: string;
kind: string;
scopeKey?: string | null;
refCode?: string | null;
traceLotNo?: string | null;
traceItemCode?: string | null;
/** Warehouse code for multi-location visual differentiation. */
warehouseCode?: string | null;
}

export interface ItemLotTraceGraph {
nodes: ItemLotTraceGraphNodeRef[];
edges: ItemLotTraceGraphEdge[];
}

export interface ItemLotTraceResponse {
lot: ItemLotTraceLotInfo;
warehouseLines: ItemLotTraceWarehouseLine[];
origins: ItemLotTraceOrigin[];
purchaseEvents?: ItemLotTracePurchaseEvent[];
qcResults: ItemLotTraceQcResult[];
putawayEvents: ItemLotTracePutawayEvent[];
movements: ItemLotTraceMovement[];
outboundUsage: ItemLotTraceOutboundUsage[];
stockTakeEvents: ItemLotTraceStockTakeEvent[];
adjustments: ItemLotTraceAdjustment[];
transfers: ItemLotTraceTransfer[];
bomTrace: ItemLotTraceBomTrace;
joPrelude: ItemLotTraceJoPrelude | null;
productionSteps: ItemLotTraceProductionStep[];
byproductLots: ItemLotTraceByproductLot[];
openMovements: ItemLotTraceOpenMovement[];
failEvents: ItemLotTraceFailEvent[];
doDeliveries: ItemLotTraceDoDelivery[];
replenishmentEvents?: ItemLotTraceReplenishmentEvent[];
returnEvents: ItemLotTraceReturnEvent[];
lotRelations: ItemLotTraceLotRelation[];
alternateLocations: ItemLotTraceAlternateLocation[];
/** Per-location full trace blocks for alternate inventory_lot rows sharing same lotNo+itemId.
* Empty when there is only one location for this lot. */
locationBlocks?: ItemLotTraceLocationBlock[];
traceGraph?: ItemLotTraceGraph | null;
}

export interface ItemLotTraceParams {
stockInLineId?: number;
inventoryLotLineId?: number;
lotNo?: string;
itemId?: number;
itemCode?: string;
}

+ 211
- 0
src/app/api/itemTracing/schema.ts Ver arquivo

@@ -0,0 +1,211 @@
import { z } from "zod";

const nullableStr = z.string().nullable().optional();
const nullableNum = z.number().nullable().optional();

const ItemLotTraceOriginSchema = z
.object({
stockInLineId: z.number(),
type: z.string(),
refCode: z.string(),
refId: nullableNum,
supplierCode: z.string(),
supplierName: z.string(),
dnNo: z.string(),
receiptDate: nullableStr,
acceptedQty: z.number(),
status: z.string(),
})
.passthrough();

const ItemLotTraceQcResultSchema = z
.object({
stockInLineId: z.number(),
qcPassed: z.boolean(),
failQty: z.number(),
acceptedQty: z.number(),
remarks: z.string(),
handledBy: z.string(),
created: nullableStr,
})
.passthrough();

const ItemLotTracePutawayEventSchema = z
.object({
inventoryLotLineId: z.number(),
stockInLineId: nullableNum,
warehouseCode: z.string(),
qty: z.number(),
handledBy: z.string(),
timestamp: nullableStr,
refType: z.string(),
refCode: z.string(),
refId: nullableNum,
status: z.string().optional(),
})
.passthrough();

const ItemLotTraceProductionStepSchema = z
.object({
processLineId: z.number(),
stepName: z.string(),
description: z.string(),
equipmentCode: z.string(),
equipmentName: z.string(),
operatorName: z.string(),
startTime: nullableStr,
endTime: nullableStr,
outputQty: z.number(),
scrapQty: z.number(),
defectQty: z.number(),
status: z.string(),
seqNo: nullableNum,
bomProcessId: nullableNum,
})
.passthrough();

const ItemLotTraceMaterialInputSchema: z.ZodType<Record<string, unknown>> = z.lazy(() =>
z
.object({
jobOrderCode: z.string(),
jobOrderId: nullableNum,
materialItemCode: z.string(),
materialItemName: z.string(),
materialLotNo: z.string(),
materialStockInLineId: nullableNum,
materialInventoryLotId: nullableNum,
materialQty: z.number(),
materialUom: z.string().optional(),
bomQtyPerUnit: nullableNum,
bomProcessId: nullableNum,
bomProcessSeqNo: nullableNum,
assignedStepName: z.string().optional(),
pickOrderCode: z.string(),
pickOrderId: nullableNum,
consoCode: z.string(),
pickedAt: nullableStr,
stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(),
purchaseEvents: z.array(z.record(z.unknown())).optional(),
qcResults: z.array(ItemLotTraceQcResultSchema),
putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(),
nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(),
productionSteps: z.array(ItemLotTraceProductionStepSchema).optional(),
})
.passthrough(),
);

const ItemLotTraceJoPreludeSchema: z.ZodType<Record<string, unknown>> = z.lazy(() =>
z
.object({
jobOrder: z
.object({
jobOrderId: z.number(),
jobOrderCode: z.string(),
planStart: nullableStr,
createdAt: nullableStr.optional(),
reqQty: z.number(),
status: z.string(),
})
.passthrough(),
pickOrders: z.array(z.record(z.unknown())),
materialInputs: z.array(ItemLotTraceMaterialInputSchema),
})
.passthrough(),
);

const ItemLotTraceGraphEdgeSchema = z
.object({
fromKey: z.string(),
toKey: z.string(),
kind: z.string(),
})
.passthrough();

const ItemLotTraceGraphNodeRefSchema = z
.object({
key: z.string(),
kind: z.string(),
scopeKey: nullableStr,
refCode: nullableStr,
traceLotNo: nullableStr,
traceItemCode: nullableStr,
warehouseCode: nullableStr,
})
.passthrough();

const ItemLotTraceGraphSchema = z
.object({
nodes: z.array(ItemLotTraceGraphNodeRefSchema),
edges: z.array(ItemLotTraceGraphEdgeSchema),
})
.passthrough()
.nullable()
.optional();

export const ItemLotTraceResponseSchema = z
.object({
lot: z
.object({
inventoryLotId: z.number(),
lotNo: z.string(),
itemId: z.number(),
itemCode: z.string(),
itemName: z.string(),
expiryDate: nullableStr,
productionDate: nullableStr,
stockInDate: nullableStr,
uom: z.string(),
primaryStockInLineId: nullableNum,
})
.passthrough(),
warehouseLines: z.array(z.record(z.unknown())),
origins: z.array(ItemLotTraceOriginSchema),
purchaseEvents: z.array(z.record(z.unknown())).optional(),
qcResults: z.array(ItemLotTraceQcResultSchema),
putawayEvents: z.array(ItemLotTracePutawayEventSchema),
movements: z.array(z.record(z.unknown())),
outboundUsage: z.array(z.record(z.unknown())),
stockTakeEvents: z.array(z.record(z.unknown())),
adjustments: z.array(z.record(z.unknown())),
transfers: z.array(z.record(z.unknown())),
bomTrace: z.record(z.unknown()),
joPrelude: ItemLotTraceJoPreludeSchema.nullable(),
productionSteps: z.array(ItemLotTraceProductionStepSchema),
byproductLots: z.array(z.record(z.unknown())),
openMovements: z.array(z.record(z.unknown())),
failEvents: z.array(z.record(z.unknown())),
doDeliveries: z.array(z.record(z.unknown())),
replenishmentEvents: z.array(z.record(z.unknown())).optional(),
returnEvents: z.array(z.record(z.unknown())),
lotRelations: z.array(z.record(z.unknown())),
alternateLocations: z.array(z.record(z.unknown())),
locationBlocks: z.array(z.record(z.unknown())).optional(),
traceGraph: ItemLotTraceGraphSchema,
})
.passthrough();

export type ItemLotTraceResponseParsed = z.infer<typeof ItemLotTraceResponseSchema>;

export const parseItemLotTraceResponse = (data: unknown): ItemLotTraceResponseParsed =>
ItemLotTraceResponseSchema.parse(data);

/** Collect dot-path keys for contract snapshot tests. */
export const collectObjectPaths = (
obj: unknown,
prefix = "",
depth = 0,
maxDepth = 6,
): string[] => {
if (obj == null || depth > maxDepth) return prefix ? [prefix] : [];
if (Array.isArray(obj)) {
if (obj.length === 0) return prefix ? [prefix] : [];
return collectObjectPaths(obj[0], prefix ? `${prefix}[]` : "[]", depth + 1, maxDepth);
}
if (typeof obj !== "object") return prefix ? [prefix] : [];
const paths: string[] = prefix ? [prefix] : [];
Object.keys(obj as Record<string, unknown>).sort().forEach((key) => {
const next = prefix ? `${prefix}.${key}` : key;
paths.push(...collectObjectPaths((obj as Record<string, unknown>)[key], next, depth + 1, maxDepth));
});
return paths;
};

+ 80
- 3
src/app/api/jo/actions.ts Ver arquivo

@@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse {
itemCode: string;
itemName: string;
bomDescription?: string | null;
/** BOM.type (e.g. drink / Powder_Mixture / other). */
bomType?: string | null;
lotNo: string;
requiredQty: number;
jobOrderId: number;
@@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse {
productProcessLineCount: number;
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
isPicked?: boolean | null;
/** Fine-grained pick/process bucket from backend. */
pickProcessBucket?: ProductionProcessFinePickBucket | null;
}

/** Fine-grained buckets returned per row. */
export type ProductionProcessFinePickBucket =
| "not_picked_not_started"
| "picked_not_started"
| "picked_started"
| "not_picked_started";

/** Merged tab filter: pending = not started; processing = started. */
export type ProductionProcessPickBucket = "pending" | "processing" | ProductionProcessFinePickBucket;

export interface JobOrderProductProcessBucketCounts {
notPickedNotStarted: number;
pickedNotStarted: number;
pickedStarted: number;
notPickedStarted: number;
}

export interface JobOrderProductProcessPageResponse {
@@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse {
totalJobOrders: number;
page: number;
size: number;
bucketCounts?: JobOrderProductProcessBucketCounts | null;
searchDate?: string | null;
carriedOverCount?: number | null;
}
export interface ProductProcessInfoResponse {
id: number;
@@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
itemCode?: string | null;
bomDescription?: string | null;
/** BOM.type (e.g. drink / Powder_Mixture / other). */
bomType?: string | null;
lotNo: string | null;
planStart?: string | number[] | null;
reqQty: number;
@@ -877,8 +905,9 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order planStart 區間起(YYYY-MM-DD,含當日) */
/** Job order / process date(YYYY-MM-DD) */
date?: string | null;
itemCode?: string | null;
jobOrderCode?: string | null;
@@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway?: boolean | null;
/** all | completed | notCompleted */
putawayStatus?: string | null;
/** Production list carry-over window (days before date). */
lookbackDays?: number | null;
/** Pick/process tab filter when lookbackDays is set. */
bucket?: ProductionProcessPickBucket | "all" | null;
page?: number;
size?: number;
}) => {
@@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway,
putawayStatus,
type,
lookbackDays,
bucket,
page = 0,
size = 50,
} = params;
@@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
queryParts.push(`includePutaway=${includePutaway}`);
}
if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`);
if (lookbackDays !== undefined && lookbackDays !== null) {
queryParts.push(`lookbackDays=${lookbackDays}`);
}
if (bucket) queryParts.push(`bucket=${encodeURIComponent(bucket)}`);
queryParts.push(`page=${page}`);
queryParts.push(`size=${size}`);

@@ -1203,8 +1242,15 @@ export const fetchJobOrderPickOrdersrecords = async (
) => {
const params = new URLSearchParams();

if (date && String(date).trim() !== "") {
params.set("date", String(date).trim());
// Backend expects LocalDate (YYYY-MM-DD); strip any time component.
const dateOnly = (() => {
if (!date || String(date).trim() === "") return null;
const match = String(date).trim().match(/(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : String(date).trim().slice(0, 10);
})();

if (dateOnly) {
params.set("date", dateOnly);
}
if (status && String(status).trim() !== "" && String(status) !== "All") {
params.set("status", String(status).trim());
@@ -1636,6 +1682,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => {
});
});

// ===== Drink Production Qty Dashboard =====
export interface DrinkProductionQtyJobOrderDetail {
jobOrderId: number;
jobOrderCode?: string | null;
productionDate?: string | null;
reqQty: number;
productionQty: number;
}

export interface DrinkProductionQtyResponse {
itemCode?: string | null;
itemName?: string | null;
uom?: string | null;
totalReqQty: number;
totalQty: number;
jobOrders?: DrinkProductionQtyJobOrderDetail[];
}

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
export const fetchDrinkProductionQty = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date);
const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`;

return serverFetchJson<DrinkProductionQtyResponse[]>(url, {
method: "GET",
next: { tags: ["drinkProductionQty"] },
});
});

// ===== Equipment Status Dashboard =====

export interface EquipmentStatusProcessInfo {


+ 24
- 0
src/app/api/settings/item/actions.ts Ver arquivo

@@ -95,6 +95,30 @@ export interface ItemWithDetails {
uomDesc: string;
currentStockBalance: number;
}

export interface PickUomOption {
uomId: number;
uomCode: string;
uomDesc: string;
}

export interface AvailablePickUomsResponse {
itemId: number;
stockUom: PickUomOption | null;
options: PickUomOption[];
}

/** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */
export const fetchAvailablePickUoms = cache(async (itemId: number) => {
return serverFetchJson<AvailablePickUomsResponse>(
`${BASE_API_URL}/items/${itemId}/available-pick-uoms`,
{
method: "GET",
next: { tags: ["items"] },
},
);
});

export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, any>) => {
if (searchParams) {
const queryString = new URLSearchParams(searchParams).toString();


+ 3
- 10
src/app/utils/fetchUtil.ts Ver arquivo

@@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ServerFetchError } from "./serverFetchError";

export { ServerFetchError };

export interface BlobResponse {
filename: string;
@@ -21,16 +24,6 @@ export interface searchParamsProps {
searchParams: { [key: string]: string | string[] | undefined };
}

export class ServerFetchError extends Error {
public readonly response: Response | undefined;
constructor(message?: string, response?: Response) {
super(message);
this.response = response;

Object.setPrototypeOf(this, ServerFetchError.prototype);
}
}

export async function serverFetchWithNoContent(...args: FetchParams) {
const response = await serverFetch(...args);



+ 21
- 0
src/app/utils/serverFetchError.ts Ver arquivo

@@ -0,0 +1,21 @@
/** Client-safe error type thrown by server fetch helpers. */
export class ServerFetchError extends Error {
public readonly response: Response | undefined;

constructor(message?: string, response?: Response) {
super(message);
this.response = response;
Object.setPrototypeOf(this, ServerFetchError.prototype);
}
}

/** Works in client components after server actions (errors may lose prototype). */
export const isNotFoundServerFetchError = (e: unknown): boolean => {
if (e instanceof ServerFetchError) {
return e.response?.status === 404;
}
if (e instanceof Error) {
return /\b404\b/.test(e.message);
}
return false;
};

+ 1
- 0
src/authorities.ts Ver arquivo

@@ -9,6 +9,7 @@ export const AUTH = {
ADMIN: "ADMIN",
STOCK: "STOCK",
INVENTORY_ADJUST: "INVENTORY_ADJUST",
ITEM_TRACING: "ITEM_TRACING",
PURCHASE: "PURCHASE",
STOCK_TAKE: "STOCK_TAKE",
STOCK_IN_BIND: "STOCK_IN_BIND",


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Ver arquivo

@@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = {
"/scheduling/detailed": "nav.breadcrumb.schedulingDetailed",
"/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit",
"/inventory": "nav.breadcrumb.inventory",
"/itemTracing": "nav.breadcrumb.itemTracing",
"/settings/importTesting": "nav.breadcrumb.importTesting",
"/settings/m18ImportTesting": "nav.breadcrumb.importTesting",
"/do": "nav.deliveryOrder",


+ 13
- 3
src/components/DoWorkbench/DoWorkbenchTabs.tsx Ver arquivo

@@ -58,6 +58,7 @@ function TabPanel(props: { value: number; index: number; children: React.ReactNo
return <Box sx={{ pt: 2 }}>{children}</Box>;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCombo = [] }) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -74,6 +75,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
urlTargetDateRaw && urlTargetDateRaw.trim() !== ""
? decodeURIComponent(urlTargetDateRaw.trim())
: null;
/** Item Tracing deep-link only; DO finish redirect must not set this. */
const urlOpenDetail = searchParams.get("openDetail") === "1";

const [tab, setTab] = React.useState<number>(defaultTabIndex);
const [lanePanelPrefs, setLanePanelPrefs] = React.useState<WorkbenchLanePanelPrefs>(
@@ -147,10 +150,11 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
setTab(newTab);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", String(newTab));
/* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */
if (newTab !== 2) {
/* ticketNo / targetDate / openDetail deep-link for Finished Good Record tabs */
if (newTab !== 2 && newTab !== 3) {
params.delete("ticketNo");
params.delete("targetDate");
params.delete("openDetail");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
@@ -380,21 +384,26 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
</TabPanel>
<TabPanel value={tab} index={2}>
<GoodPickExecutionWorkbenchRecord
key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`}
key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`}
printerCombo={printerCombo}
listScope="mine"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
initialTicketNo={urlTicketNo}
initialTargetDate={urlTargetDate}
openDetail={urlOpenDetail}
/>
</TabPanel>
<TabPanel value={tab} index={3}>
<GoodPickExecutionWorkbenchRecord
key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`}
printerCombo={printerCombo}
listScope="all"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
initialTicketNo={urlTicketNo}
initialTargetDate={urlTargetDate}
openDetail={urlOpenDetail}
/>
</TabPanel>
<TabPanel value={tab} index={4}>
@@ -411,6 +420,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
const DoWorkbenchTabs: React.FC<Props> = (props) => (
<Suspense
fallback={


+ 16
- 1
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx Ver arquivo

@@ -1,6 +1,6 @@
"use client";

import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
AccordionDetails,
@@ -48,8 +48,11 @@ type Props = {
labelPrinter: PrinterCombo | null;
initialTicketNo?: string | null;
initialTargetDate?: string | null;
/** When true (Item Tracing `openDetail=1`), auto-open matching ticket detail. */
openDetail?: boolean;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
printerCombo,
listScope = "mine",
@@ -57,6 +60,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
labelPrinter,
initialTicketNo,
initialTargetDate,
openDetail = false,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -507,6 +511,17 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
[],
);

const initialDetailOpenedRef = useRef(false);
useEffect(() => {
if (!openDetail) return;
if (initialDetailOpenedRef.current || loading || !initialTicketNo?.trim()) return;
const tn = initialTicketNo.trim();
const match = records.find((r) => r.ticketNo?.trim() === tn);
if (!match) return;
initialDetailOpenedRef.current = true;
void handleDetailClick(match);
}, [openDetail, records, loading, initialTicketNo, handleDetailClick]);

const handleBackToList = useCallback(() => {
setShowDetailView(false);
setSelectedRecord(null);


+ 59
- 17
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Ver arquivo

@@ -40,6 +40,7 @@ interface Props {
type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn };
type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] };

/** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */
const WorkbenchFloorLanePanel: React.FC<Props> = ({
onPickOrderAssigned,
onSwitchToDetailTab,
@@ -85,7 +86,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState<string | undefined>(undefined);
const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState<string | undefined>(undefined);
const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(undefined);
const defaultTruckCount = summary4F?.defaultTruckCount ?? 0;
const [modalTruckXFloor, setModalTruckXFloor] = useState<string | undefined>(undefined);
const ticketFloorApiKey = useMemo(
() => ticketFloor.replace("/", "").trim().toUpperCase(),
[ticketFloor],
);
const defaultTruckCount =
(ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0;
const etraEnterInFlightRef = useRef(false);
const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false);

@@ -108,16 +115,25 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
shopName?: string,
storeId?: string,
truck?: string,
releaseType?: string
) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType),
releaseType?: string,
floor?: string
) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType, floor),
loadToday: (
shopName?: string,
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
releaseType?: string
releaseType?: string,
floor?: string
) =>
fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType),
fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
shopName,
storeId,
truck,
requiredDeliveryDate,
releaseType,
floor
),
assignByListItemId: assignByDeliveryOrderPickOrderId,
}),
[],
@@ -234,7 +250,14 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
pendingRef.current += 1;
startFullTimer();
try {
const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X");
// storeId stays undefined (Truck X); floor splits by DO supplier preferred floor.
const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(
undefined,
undefined,
"車線-X",
undefined,
ticketFloorApiKey
);
setBeforeTodayTruckXCount(list.length);
} catch {
setBeforeTodayTruckXCount(0);
@@ -244,12 +267,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
}
};
void loadBeforeTodayTruckX();
}, [inEtraUi]);
}, [inEtraUi, ticketFloorApiKey]);

const clearModalEtraContext = useCallback(() => {
setModalReleaseTypeFilter(undefined);
setModalFilterRequiredDeliveryDate(undefined);
setModalInitialShopSearch(undefined);
setModalTruckXFloor(undefined);
}, []);

const openEnterEtraView = useCallback(async () => {
@@ -538,6 +562,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("today");
setModalTruckXFloor(ticketFloorApiKey);
setModalOpen(true);
}}
>
@@ -638,10 +663,11 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
variant="outlined"
onClick={() => {
clearModalEtraContext();
setSelectedStore("4/F");
setSelectedStore("");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("before");
setModalTruckXFloor(ticketFloorApiKey);
setModalOpen(true);
}}
>
@@ -664,11 +690,17 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
group.lanes.map((lane, li) => {
const sid = (lane.storeId ?? "").trim();
const dep = (lane.truckDepartureTime ?? "").trim();
const is4F = sid.replace(/\//g, "").toUpperCase() === "4F";
const isTruckX =
(lane.truckLanceCode ?? "").trim() === "車線-X" ||
(lane.truckLanceCode ?? "").trim() === "Truck X";
const floorKey = sid.replace(/\//g, "").toUpperCase();
const is4F = floorKey === "4F";
const labelCore =
is4F && lane.loadingSequence != null
? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
: `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;
isTruckX
? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})`
: is4F && lane.loadingSequence != null
? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
: `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;
const handlerName = (lane.handlerName ?? "").trim();
const shopCode = (group.shopCode ?? "").trim();
const shopName = (group.shopName ?? "").trim();
@@ -677,20 +709,29 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
? `${shopCode} · ${shopName}`
: shopName || shopCode || t("Shop");
const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`;
const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${li}`;
const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${sid}|${li}`;
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={tileKey}>
<Button
fullWidth
variant="outlined"
disabled={lane.unassigned === 0 || !sid}
disabled={lane.unassigned === 0 || (!isTruckX && !sid)}
onClick={() => {
setModalReleaseTypeFilter("isExtra");
setModalFilterRequiredDeliveryDate(selectedDeliveryDateYmd);
setModalInitialShopSearch((group.shopName || group.shopCode || "").trim() || undefined);
setSelectedStore(sid);
setSelectedTruck(lane.truckLanceCode);
setIsDefaultTruck(false);
if (isTruckX) {
// Truck X: DB storeId stays null; use display floor + default-truck list path.
setSelectedStore("");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setModalTruckXFloor(floorKey || undefined);
} else {
setSelectedStore(sid);
setSelectedTruck(lane.truckLanceCode);
setIsDefaultTruck(false);
setModalTruckXFloor(undefined);
}
setDefaultDateScope("today");
setModalOpen(true);
}}
@@ -746,6 +787,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
releaseTypeFilter={modalReleaseTypeFilter}
filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate}
initialShopSearch={modalInitialShopSearch}
truckXFloor={modalTruckXFloor}
onClose={() => {
setModalOpen(false);
clearModalEtraContext();


+ 215
- 55
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Ver arquivo

@@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>)
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */
const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
filterArgs,
onSwitchToRecordTab,
@@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
itemId: line.item.id,
itemCode: line.item.code,
itemName: line.item.name,
uomId: line.item.uomId ?? null,
uomDesc: line.item.uomDesc,
uomShortDesc: line.item.uomShortDesc,
@@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
itemId: line.item.id,
itemCode: line.item.code,
itemName: line.item.name,
uomId: line.item.uomId ?? null,
uomDesc: line.item.uomDesc,
uomShortDesc: line.item.uomShortDesc,
@@ -1220,13 +1223,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
}, [fetchAllCombinedLotData]);

const openWorkbenchLotLabelModalForLot = useCallback(
(lot: any, reminderText?: string | null) => {
(
lot: any,
reminderText?: string | null,
options?: { markAsScanIssue?: boolean },
) => {
const itemId = Number(lot?.itemId);
const stockInLineId = Number(lot?.stockInLineId);
const solId = Number(lot?.stockOutLineId);
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) {
return;
}
// 掃碼 issue 才標記:手動「批號二維碼」不傳 markAsScanIssue,批號不變紅
if (options?.markAsScanIssue) {
rememberWorkbenchScanReject(solId, reminderText);
}
setWorkbenchLotLabelContextLot(lot);
if (Number.isFinite(stockInLineId) && stockInLineId > 0) {
setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId });
@@ -1238,7 +1249,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
setQrScanSuccess(false);
setWorkbenchLotLabelModalOpen(true);
},
[],
[rememberWorkbenchScanReject],
);

const shouldOpenWorkbenchLotLabelModalForFailure = useCallback(
@@ -1688,14 +1699,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
});
return;
}
// ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed)
const lookupStartTime = performance.now();
const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
// ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected
const allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
let allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
const lookupTime = performance.now() - lookupStartTime;
console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`);

// Case A: reject only if scanned lot UOM matches none of this item's POL UOMs
// (same item may have multiple lines with different UOMs on one pick order)
let scannedUomId = 0;
const allowedUomIds = new Set(
allLotsForItem
.map((l: any) => Number(l?.uomId))
.filter((id: number) => Number.isFinite(id) && id > 0),
);
const hasLineUoms = allowedUomIds.size > 0;
try {
const lotDetail = await fetchLotDetail(scannedStockInLineId);
scannedUomId = Number(lotDetail?.uomId) || 0;
} catch (e) {
if (hasLineUoms) {
console.warn(
"[QR PROCESS] lot-detail UOM check failed; rejecting scan",
e,
);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
t(
"This lot UOM does not match the pick line. Please scan another lot.",
),
);
});
lastProcessedQrRef.current = "";
processedQrCodesRef.current.delete(latestQr);
return;
}
}
if (
hasLineUoms &&
(!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId))
) {
console.warn(
` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`,
);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
t(
"This lot UOM does not match the pick line. Please scan another lot.",
),
);
});
lastProcessedQrRef.current = "";
processedQrCodesRef.current.delete(latestQr);
return;
}

// Strict UOM filter — never fall back to other UOM lines
if (scannedUomId > 0 && hasLineUoms) {
allLotsForItem = allLotsForItem.filter(
(l: any) => Number(l?.uomId) === scannedUomId,
);
activeSuggestedLots = activeSuggestedLots.filter(
(l: any) => Number(l?.uomId) === scannedUomId,
);
if (allLotsForItem.length === 0) {
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
t(
"This lot UOM does not match the pick line. Please scan another lot.",
),
);
});
lastProcessedQrRef.current = "";
processedQrCodesRef.current.delete(latestQr);
return;
}
}
// ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
// This allows users to scan other lots even when all suggested lots are rejected
@@ -1746,6 +1834,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
openWorkbenchLotLabelModalForLot(
scannedLot,
t("This lot is not available, please scan another lot."),
{ markAsScanIssue: true },
);
return;
}
@@ -1761,6 +1850,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
openWorkbenchLotLabelModalForLot(
scannedLot,
`Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
{ markAsScanIssue: true },
);
return;
}
@@ -1883,7 +1973,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
markAsScanIssue: true,
});
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2055,7 +2147,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
markAsScanIssue: true,
});
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2250,7 +2344,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
exactMatch
) {
openWorkbenchLotLabelModalForLot(exactMatch, failMsg);
openWorkbenchLotLabelModalForLot(exactMatch, failMsg, {
markAsScanIssue: true,
});
return;
}
if (workbenchMode && exactMatch.stockOutLineId != null) {
@@ -2394,7 +2490,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
markAsScanIssue: true,
});
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2646,7 +2744,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const qrDetectionStartTime = performance.now();
console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
//console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`);
const qrPayload = parseWorkbenchScanQrPayload(latestQr);
@@ -3176,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
? { stockInLineId: canonicalLotForSol.stockInLineId }
: {}),
qty: qtyToSend,
justComplete: true,
storeId: fgPickOrders?.[0]?.storeId ?? null,
userId: currentUserId ?? 1,
});
@@ -3457,7 +3556,7 @@ const handleStartScan = useCallback(() => {
resetScan();
lastConsumedQrValuesLengthRef.current = 0;
}, [stopScan, resetScan]);
// ... existing code around line 1469 ...
const handlelotnull = useCallback(async (lot: any) => {
// 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId
const stockOutLineId = lot.stockOutLineId;
@@ -4074,10 +4173,21 @@ const handleSubmitAllScanned = useCallback(async () => {
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Route")}</TableCell>
<TableCell>{t("Suggest Lot No.")}</TableCell>
<TableCell align="right">{t("Lot Required Pick Qty")}</TableCell>
<TableCell align="center">{t("Scan Result")}</TableCell>
<TableCell
align="center"
sx={{ minWidth: 220, whiteSpace: "nowrap" }}
>
{`${t("Route")} / ${t("Suggest Lot No.")}`}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}>
{t("Lot Required Pick Qty")}
</TableCell>
<TableCell
align="center"
sx={{ width: 88, minWidth: 88, px: 0.5, whiteSpace: "nowrap" }}
>
{t("Scan Result")}
</TableCell>
{/*<TableCell align="center">{t("Qty will submit")}</TableCell>*/}
<TableCell align="center">{t("Submit Required Pick Qty")}</TableCell>
</TableRow>
@@ -4085,7 +4195,7 @@ const handleSubmitAllScanned = useCallback(async () => {
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={11} align="center">
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
@@ -4106,7 +4216,7 @@ paginatedData.map((row, index) => {
? Number(fromPickRow)
: lockedSubmitQtyDisplay;

// 检查是否是 issue lot
const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo;
const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
@@ -4131,45 +4241,64 @@ paginatedData.map((row, index) => {
}}
>
<TableCell>
<Typography variant="body2" fontWeight="bold">
<Typography variant="body1" fontWeight="bold">
{row.isGroupFirst ? row.groupDisplayIndex : ""}
</Typography>
</TableCell>

<TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell>
<TableCell>
{row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
<Typography variant="body1">
{row.isGroupFirst ? lot.itemCode : ""}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{lot.routerRoute || '-'}
<TableCell>
<Typography variant="body1">
{row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ flex: 1, minWidth: 0 }}>
<TableCell align="center" sx={{ minWidth: 220 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
sx={{ width: "100%" }}
>
<Box sx={{ textAlign: "center" }}>
<Typography variant="body2" sx={{ whiteSpace: "nowrap", textAlign: "center" }}>
{lot.routerRoute || "-"}
</Typography>
{(() => {
const hasLotNo = Boolean(lot.lotNo);
const isExpired = lot.lotAvailability === 'expired';
const isUnavailable = isInventoryLotLineUnavailable(lot);
const isNoLotHint = !hasLotNo && !rejectDisplay; // 顯示「請檢查周圍…」那種
const isIssueText =
Boolean(rejectDisplay) || !hasLotNo || isExpired || isUnavailable;
const textColor =
isNoLotHint
? 'error.main' // 或 'text.primary':固定黑,不受 handled / unavailable 影響
: rejectDisplay || isSolRejected || isUnavailable
? 'error.main'
: isExpired
? 'warning.main'
: 'inherit';
const solStatus = String(lot.stockOutLineStatus ?? "").toLowerCase();
const isComplete =
solStatus === "completed" ||
solStatus === "checked" ||
solStatus === "partially_completed" ||
solStatus === "partially_complete";
const textColor =
isNoLotHint
? "error.main"
: rejectDisplay || isSolRejected || isUnavailable
? "error.main"
: isExpired
? "warning.main"
: isComplete
? "success.main"
: "inherit";
const lotOnly =
hasLotNo && !rejectDisplay && !isExpired && !isUnavailable;
return (
<Typography
variant={isIssueText ? "body2" : "body1"}
variant="body2"
sx={{
color: textColor,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
whiteSpace: lotOnly ? "nowrap" : "pre-wrap",
wordBreak: lotOnly ? "normal" : "break-word",
textAlign: "center",
}}
>
{hasLotNo ? (
@@ -4213,7 +4342,6 @@ paginatedData.map((row, index) => {
sx={{
flexShrink: 0,
fontSize: "0.75rem",
py: 0.25,
minWidth: "auto",
px: 1,
whiteSpace: "nowrap",
@@ -4224,23 +4352,34 @@ paginatedData.map((row, index) => {
)}
</Stack>
</TableCell>
<TableCell align="right">
{(() => {
const requiredQty = lot.requiredQty || 0;
return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
})()}

<TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}>
<Typography variant="body1">
{(() => {
const requiredQty = lot.requiredQty || 0;
return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
})()}
</Typography>
</TableCell>
<TableCell align="center">
<TableCell align="center" sx={{ width: 88, minWidth: 88, px: 0.5 }}>
{(() => {
const status = lot.stockOutLineStatus?.toLowerCase();
const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
const isNoLot = !lot.lotNo;
const scanResultSlotSx = {
width: 42,
height: 42,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mx: 'auto',
} as const;
// rejected lot:显示红色勾选(已扫描但被拒绝)
if (isRejected && !isNoLot) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box sx={scanResultSlotSx}>
<Checkbox
checked={true}
disabled={true}
@@ -4264,13 +4403,13 @@ paginatedData.map((row, index) => {
status !== "partially_completed" &&
status !== "partially_complete"
) {
return null;
return <Box sx={scanResultSlotSx} />;
}

// 正常 lot:已扫描(checked/partially_completed/completed)
if (!isNoLot && status !== 'pending' && status !== 'rejected') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box sx={scanResultSlotSx}>
<Checkbox
checked={true}
disabled={true}
@@ -4289,7 +4428,7 @@ paginatedData.map((row, index) => {
// noLot 且已完成/部分完成:显示红色勾选
if (isNoLot && (status === 'partially_completed' || status === 'completed')) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Box sx={scanResultSlotSx}>
<Checkbox
checked={true}
disabled={true}
@@ -4305,7 +4444,7 @@ paginatedData.map((row, index) => {
);
}
return null;
return <Box sx={scanResultSlotSx} />;
})()}
</TableCell>
{/*
@@ -4391,11 +4530,10 @@ paginatedData.map((row, index) => {
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
{isRowPicked ? (
<Typography
variant="body2"
variant="body1"
sx={{
width: 96,
textAlign: "center",
fontSize: "1rem",
fontWeight: 500,
}}
>
@@ -4426,7 +4564,16 @@ paginatedData.map((row, index) => {
inputProps={{ min: 0, step: 1 }}
sx={{
width: 96,
"& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center" },
"& .MuiInputBase-input": {
typography: "body1",
py: 0.5,
textAlign: "center",
"&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
MozAppearance: "textfield",
},
}}
/>
)}
@@ -4471,7 +4618,14 @@ paginatedData.map((row, index) => {
}
sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }}
sx={{
fontSize: "0.7rem",
py: 0.5,
minHeight: "28px",
minWidth: "72px",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{t("Just Completed")}
</Button>
@@ -4556,6 +4710,12 @@ paginatedData.map((row, index) => {
).trim() || null
: null
}
expectedUomId={
workbenchLotLabelContextLot != null &&
Number(workbenchLotLabelContextLot.uomId) > 0
? Number(workbenchLotLabelContextLot.uomId)
: null
}
disableScanPick={workbenchLotLabelScanPickDisabled}
onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
submitQty={workbenchLotLabelSubmitQty}


+ 60
- 19
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Ver arquivo

@@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = {
inventoryLotLineId: number;
warehouseCode?: string | null;
warehouseName?: string | null;
uom?: string | null;
uomId?: number | null;
} | null;
sameItemLots: Array<{
lotNo: string;
@@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = {
stockInLineId?: number | null;
availableQty: number;
uom: string;
uomId?: number | null;
warehouseCode?: string | null;
warehouseName?: string | null;
}>;
@@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps {
/** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */
triggerLotAvailableQty?: number | null;
triggerLotUom?: string | null;
/** POL UomConversion id — workbench lot list only returns matching UOM */
expectedUomId?: number | null;
/** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */
disableScanPick?: boolean;
/**
@@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean {
return s.includes("label") && !s.includes("a4");
}

/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */
const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({
open,
onClose,
@@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
hideTriggeredLot = false,
triggerLotAvailableQty = null,
triggerLotUom = null,
expectedUomId = null,
disableScanPick = false,
onWorkbenchScanPick,
submitQty = null,
@@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
if (id != null) setSelectedPrinterId(id);
}, [open, printers, selectedPrinterId, pickDefaultPrinterId]);

const resolveExpectedUomId = useCallback((): number | null => {
const n = Number(expectedUomId);
return Number.isFinite(n) && n > 0 ? n : null;
}, [expectedUomId]);

const analyzePayload = useCallback(
async (payload: ScanPayload) => {
setLastPayload(payload);
setScanError(null);
setAnalysisLoading(true);
try {
const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse;
const uomId = resolveExpectedUomId();
const data = (await analyzeWorkbenchQrCode({
...payload,
...(uomId != null ? { uomId } : {}),
})) as QrCodeAnalysisResponse;
setAnalysis(data);
setSnackbar({
open: true,
@@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
setAnalysisLoading(false);
}
},
[],
[resolveExpectedUomId],
);

const analyzeByItem = useCallback(
@@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
setScanError(null);
setAnalysisLoading(true);
try {
const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as {
const uomId = resolveExpectedUomId();
const data = (await fetchWorkbenchAvailableLotsByItem(
itemId,
uomId,
)) as {
itemId: number;
itemCode: string;
itemName: string;
@@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
setAnalysisLoading(false);
}
},
[],
[resolveExpectedUomId],
);

const handleAnalyze = useCallback(async () => {
@@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0;
const fromApi = Number(scannedRow?.availableQty ?? 0);
const scanned = analysis.scanned;
const scannedLot = scannedLotLineId
? {
lotNo: scanned?.lotNo ?? "",
inventoryLotLineId: scannedLotLineId,
stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
availableQty: Math.max(fromApi, fromTable) as number,
uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string,
warehouseCode:
scanned?.warehouseCode ?? scannedRow?.warehouseCode,
warehouseName:
scanned?.warehouseName ?? scannedRow?.warehouseName,
_scanned: true as const,
}
: null;
const expectUom = Number(expectedUomId);
const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0;
const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0);
const scannedUomOk =
!hasExpectUom ||
!Number.isFinite(scannedUomId) ||
scannedUomId <= 0 ||
scannedUomId === expectUom;
const scannedLot =
scannedLotLineId && scannedUomOk
? {
lotNo: scanned?.lotNo ?? "",
inventoryLotLineId: scannedLotLineId,
stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
availableQty: Math.max(fromApi, fromTable) as number,
uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string,
uomId: scannedUomId > 0 ? scannedUomId : null,
warehouseCode:
scanned?.warehouseCode ?? scannedRow?.warehouseCode,
warehouseName:
scanned?.warehouseName ?? scannedRow?.warehouseName,
_scanned: true as const,
}
: null;

const merged = [
...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
...list
.filter((x) => x.inventoryLotLineId !== scannedLotLineId)
.filter((x) => {
if (!hasExpectUom) return true;
const id = Number(x.uomId);
return !Number.isFinite(id) || id <= 0 || id === expectUom;
})
.map((x) => ({ ...x, _scanned: false as const })),
];

return merged;
}, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]);
}, [
analysis,
hideTriggeredLot,
triggerLotAvailableQty,
triggerLotUom,
expectedUomId,
]);

const filteredLots = useMemo(() => {
const prefix = String(warehouseCodePrefixFilter ?? "").trim();


+ 86
- 1
src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx Ver arquivo

@@ -5,13 +5,16 @@ import {
Box,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
FormControl,
InputLabel,
ListItemText,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Stack,
Table,
TableBody,
@@ -65,6 +68,14 @@ function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean {
);
}

const HANDLER_UNASSIGNED = "__UNASSIGNED__";
const HANDLER_SELECT_ALL = "__ALL__";

function handlerFilterKey(handlerName: string | null | undefined): string {
const name = handlerName?.trim() ?? "";
return name || HANDLER_UNASSIGNED;
}

const WorkbenchTicketReleaseTableTab: React.FC = () => {
const { t } = useTranslation("ticketReleaseTable");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -73,6 +84,7 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
const [selectedFloor, setSelectedFloor] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("released");
const [selectedHandlers, setSelectedHandlers] = useState<string[]>([]);
const [data, setData] = useState<WorkbenchTicketReleaseTable[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [now, setNow] = useState(dayjs());
@@ -111,6 +123,28 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
}, []);

const dayStr = queryDate.format("YYYY-MM-DD");

const handlerOptions = useMemo(() => {
const names = new Set<string>();
let hasUnassigned = false;
for (const item of data) {
if (selectedFloor && item.storeId !== selectedFloor) continue;
const name = item.handlerName?.trim();
if (name) names.add(name);
else hasUnassigned = true;
}
const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, "zh-Hant"));
return hasUnassigned ? [HANDLER_UNASSIGNED, ...sorted] : sorted;
}, [data, selectedFloor]);

useEffect(() => {
setSelectedHandlers((prev) => {
if (prev.length === 0) return prev;
const next = prev.filter((name) => handlerOptions.includes(name));
return next.length === prev.length ? prev : next;
});
}, [handlerOptions]);

const filteredData = useMemo(() => {
return data.filter((item) => {
if (selectedFloor && item.storeId !== selectedFloor) return false;
@@ -119,9 +153,27 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
if (itemDate !== dayStr) return false;
}
if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false;
if (selectedHandlers.length > 0 && !selectedHandlers.includes(handlerFilterKey(item.handlerName))) {
return false;
}
return true;
});
}, [data, dayStr, selectedFloor, selectedStatus]);
}, [data, dayStr, selectedFloor, selectedStatus, selectedHandlers]);

const allHandlersSelected =
handlerOptions.length > 0 && selectedHandlers.length === handlerOptions.length;

const handleHandlersChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
const next = typeof value === "string" ? value.split(",") : value;
if (next.includes(HANDLER_SELECT_ALL)) {
setSelectedHandlers(allHandlersSelected ? [] : handlerOptions);
setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
return;
}
setSelectedHandlers(next.filter((v) => v !== HANDLER_SELECT_ALL));
setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
};

const paginatedData = useMemo(() => {
const startIndex = paginationController.pageNum * paginationController.pageSize;
@@ -247,6 +299,39 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => {
<MenuItem value="completed">{t("completed")}</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel id="workbench-handler-select-label" shrink>
{t("Handler Name")}
</InputLabel>
<Select
labelId="workbench-handler-select-label"
multiple
value={selectedHandlers}
label={t("Handler Name")}
onChange={handleHandlersChange}
displayEmpty
renderValue={(selected) => {
if (selected.length === 0) return t("All Handlers");
return selected
.map((v) => (v === HANDLER_UNASSIGNED ? t("Unassigned") : v))
.join(", ");
}}
>
<MenuItem value={HANDLER_SELECT_ALL}>
<Checkbox
checked={allHandlersSelected}
indeterminate={selectedHandlers.length > 0 && !allHandlersSelected}
/>
<ListItemText primary={t("Select All")} />
</MenuItem>
{handlerOptions.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox checked={selectedHandlers.includes(name)} />
<ListItemText primary={name === HANDLER_UNASSIGNED ? t("Unassigned") : name} />
</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ flexGrow: 1 }} />
<Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning>


+ 21
- 7
src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx Ver arquivo

@@ -36,7 +36,9 @@ export type ReleasedDoPickListBridge = {
shopName?: string,
storeId?: string,
truck?: string,
releaseType?: string
releaseType?: string,
/** Optional `2F`/`4F` for Truck X supplier-floor split */
floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
/** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */
loadToday: (
@@ -44,7 +46,9 @@ export type ReleasedDoPickListBridge = {
storeId?: string,
truck?: string,
requiredDeliveryDate?: string,
releaseType?: string
releaseType?: string,
/** Optional `2F`/`4F` for Truck X supplier-floor split */
floor?: string
) => Promise<ReleasedDoPickOrderListItem[]>;
assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>;
};
@@ -70,8 +74,13 @@ interface Props {
* requiredDate instead of historical released (delivery date before calendar today).
*/
filterRequiredDeliveryDate?: string;
/**
* Truck X only: `2F`/`4F` (or `2/F`/`4/F`) — filter by DO supplier preferred floor; storeId stays null.
*/
truckXFloor?: string;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */
const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
open,
onClose,
@@ -85,6 +94,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
releaseTypeFilter,
initialShopSearch,
filterRequiredDeliveryDate,
truckXFloor,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -102,21 +112,25 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection;
const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday;

const floorArg = truckXFloor?.trim() || undefined;
const shopArg = shopSearch.trim() || undefined;
if (isDefaultTruck) {
if (defaultDateScopeProp === "today") {
data = await loadTodayFn(
undefined,
shopArg,
undefined,
"車線-X",
defaultTruckRequiredDeliveryDate?.trim() || undefined,
releaseTypeFilter?.trim() || undefined
releaseTypeFilter?.trim() || undefined,
floorArg
);
} else {
data = await loadReleased(
undefined,
shopArg,
undefined,
"車線-X",
releaseTypeFilter?.trim() || undefined
releaseTypeFilter?.trim() || undefined,
floorArg
);
}
} else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) {
@@ -143,7 +157,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({
} finally {
setLoading(false);
}
}, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]);
}, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate, truckXFloor]);

useEffect(() => {
loadList();


+ 7
- 6
src/components/InventorySearch/InventorySearch.tsx Ver arquivo

@@ -12,7 +12,7 @@ import {
analyzeQrCode,
SearchInventory,
SearchInventoryLotLine,
fetchInventories,
fetchInventoriesLatest,
fetchInventoryLotLines,
} from '@/app/api/inventory/actions';
import { PrinterCombo } from '@/app/api/settings/printer';
@@ -56,6 +56,7 @@ type SearchQuery = Partial<
>;
type SearchParamNames = keyof SearchQuery;

/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */
const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);

@@ -171,12 +172,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
pagingController: typeof defaultPagingController,
lotNo: string,
) => {
console.log('%c Action Type 1.', 'color:red', actionType);
//console.log('%c Action Type 1.', 'color:red', actionType);
// Avoid loading data again
if (actionType === 'paging' && pagingController === defaultPagingController) {
return;
}
console.log('%c Action Type 2.', 'color:blue', actionType);
// console.log('%c Action Type 2.', 'color:blue', actionType);

const params: SearchInventory = {
code: query?.itemCode ?? '',
@@ -187,7 +188,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
pageSize: pagingController.pageSize,
};

const response = await fetchInventories(params);
const response = await fetchInventoriesLatest(params);

if (response) {
setInventoriesTotalCount(response.total);
@@ -199,7 +200,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
break;
case 'paging':
setFilteredInventories((fi) =>
uniqBy([...fi, ...response.records], 'id'),
uniqBy([...fi, ...response.records], 'itemId'),
);
}
}
@@ -414,7 +415,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
scanUiMode,
]);

console.log('', 'color: #666', inventoriesPagingController);
//console.log('', 'color: #666', inventoriesPagingController);

const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);


+ 3
- 3
src/components/ItemPriceSearch/ItemPriceSearch.tsx Ver arquivo

@@ -24,7 +24,7 @@ type ItemPriceSearchComponent = React.FC & {
type SearchParamNames = keyof SearchQuery;

const ItemPriceSearch: ItemPriceSearchComponent = () => {
const { t } = useTranslation(["itemPrice", "inventory", "common", "importExcel"]);
const { t } = useTranslation(["itemPrice", "common", "importExcel"]);

const [item, setItem] = useState<ItemsResult | null>(null);
const [isSearching, setIsSearching] = useState(false);
@@ -275,7 +275,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => {
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Average unit price", { ns: "inventory" })}
{t("Average unit price")}
</Typography>
<Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}>
{avgPrice != null && avgPrice !== 0
@@ -292,7 +292,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => {
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Latest market unit price", { ns: "inventory" })}
{t("Latest market unit price")}
</Typography>
<Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0


+ 151
- 0
src/components/ItemTracing/ItemTracing.tsx Ver arquivo

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

import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { fetchItemLotTrace } from "@/app/api/itemTracing/actions";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError";
import ItemTracingScanBar from "./ItemTracingScanBar";
import ItemTracingSummary from "./ItemTracingSummary";
import ItemTracingFlowGraph from "./ItemTracingFlowGraph";
import ItemTracingSections from "./ItemTracingSections";
import {
compileTraceGraph,
type CompiledTraceGraph,
} from "./compileTraceGraph";
import { buildTraceGraphLabels } from "./traceGraphLabels";
import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx";

export type WarehouseFocusRequest = {
inventoryLotId: number;
warehouseCode: string;
};

const ItemTracing: React.FC = () => {
const { t } = useTranslation("itemTracing");
const searchParams = useSearchParams();
const initialTraceRan = useRef(false);
const [data, setData] = useState<ItemLotTraceResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focusWarehouse, setFocusWarehouse] =
useState<WarehouseFocusRequest | null>(null);

const runTrace = useCallback(
async (params: {
stockInLineId?: number;
inventoryLotLineId?: number;
lotNo?: string;
itemCode?: string;
}) => {
setLoading(true);
setError(null);
try {
const res = await fetchItemLotTrace(params);
setData(res);
setFocusWarehouse(null);
} catch (e) {
console.error(e);
if (isNotFoundServerFetchError(e)) {
setError(t("notFound"));
} else {
setError(t("traceError"));
}
setData(null);
} finally {
setLoading(false);
}
},
[t],
);

useEffect(() => {
if (initialTraceRan.current) return;
const stockInLineIdRaw = searchParams.get("stockInLineId");
const lotNo = searchParams.get("lotNo")?.trim();
const itemCode = searchParams.get("itemCode")?.trim();
const stockInLineId = stockInLineIdRaw
? Number(stockInLineIdRaw)
: undefined;
const hasTraceParams =
(stockInLineId != null && !Number.isNaN(stockInLineId)) ||
Boolean(lotNo) ||
Boolean(itemCode);
if (!hasTraceParams) return;
initialTraceRan.current = true;
void runTrace({
stockInLineId:
stockInLineId != null && !Number.isNaN(stockInLineId)
? stockInLineId
: undefined,
lotNo: lotNo || undefined,
itemCode: itemCode || undefined,
});
}, [runTrace, searchParams]);

const compiledGraph = useMemo((): CompiledTraceGraph | null => {
if (!data) return null;
const labels = buildTraceGraphLabels(t, data.lot.uom);
return compileTraceGraph(data, labels);
}, [data, t]);

const handleExportExcel = useCallback(() => {
if (!data || !compiledGraph) return;
exportItemLotTraceXlsx(data, compiledGraph, t);
}, [data, compiledGraph, t]);

return (
<Stack spacing={3} sx={{ mt: 2 }}>
<Typography variant="body1" color="text.secondary">
{t("subtitle")}
</Typography>

<ItemTracingScanBar
onTrace={runTrace}
loading={loading}
lastLotNo={data?.lot.lotNo}
/>

{loading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress />
</Box>
)}

{error && !loading && <Alert severity="error">{error}</Alert>}

{!loading && !data && !error && (
<Alert severity="info">{t("noResult")}</Alert>
)}

{data && !loading && (
<>
<ItemTracingSummary
data={data}
onFocusWarehouse={(request) => setFocusWarehouse(request)}
onExportExcel={compiledGraph ? handleExportExcel : undefined}
/>
{compiledGraph && (
<>
<ItemTracingFlowGraph
data={data}
compiledGraph={compiledGraph}
onTrace={runTrace}
focusWarehouse={focusWarehouse}
/>
<ItemTracingSections
data={data}
compiledGraph={compiledGraph}
onTrace={runTrace}
/>
</>
)}
</>
)}
</Stack>
);
};

export default ItemTracing;

+ 94
- 0
src/components/ItemTracing/ItemTracingDocLink.tsx Ver arquivo

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

import Link from "next/link";
import MuiLink from "@mui/material/Link";
import { isWorkbenchTicketNo, normalizeTargetDateForLink } from "./traceDocLinkUtils";

type DocLinkProps = {
kind: "jo" | "po" | "pick" | "workbench" | "jodetail";
code: string;
id?: number | null;
consoCode?: string;
ticketNo?: string;
targetDate?: string;
/** Finished Good Record tab on /doworkbench (default: 3 = all). */
workbenchTab?: number;
/** Completed JO pick record tab on /jodetail (default: 1 = complete records). */
jodetailTab?: number;
/** Open in a new browser tab (used for links inside the flow graph). */
openInNewTab?: boolean;
};

const stopGraphEvent = (e: React.MouseEvent) => {
e.stopPropagation();
};

/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.1 | 2026-07-19 */
const ItemTracingDocLink: React.FC<DocLinkProps> = ({
kind,
code,
id,
consoCode,
ticketNo,
targetDate,
workbenchTab = 3,
jodetailTab = 1,
openInNewTab = false,
}) => {
if (!code?.trim()) return <>—</>;

const linkTargetDate = normalizeTargetDateForLink(targetDate);

let href = "#";
if (kind === "jo" && id) href = `/jo/edit?id=${id}`;
else if (kind === "po" && id) href = `/po/edit?id=${id}`;
else if (kind === "workbench" && ticketNo?.trim()) {
const params = new URLSearchParams();
params.set("tab", String(workbenchTab));
params.set("ticketNo", ticketNo.trim());
if (linkTargetDate) params.set("targetDate", linkTargetDate);
// Only Item Tracing deep-links should auto-open detail (not DO finish redirect).
params.set("openDetail", "1");
href = `/doworkbench?${params.toString()}`;
} else if (kind === "jodetail" && (consoCode || code)) {
const params = new URLSearchParams();
params.set("tab", String(jodetailTab));
// Prefer PI-* pick order code for jodetail search; consoCode may be PICK-*.
params.set("pickOrderCode", (code || consoCode || "").trim());
if (linkTargetDate) params.set("targetDate", linkTargetDate);
params.set("openDetail", "1");
href = `/jodetail?${params.toString()}`;
} else if (kind === "pick") {
// /pickOrder/detail?consoCode=… is retired — only link when we have a TI-* workbench ticket.
const workbenchTicket = [ticketNo, consoCode, code]
.map((v) => (v ?? "").trim())
.find(isWorkbenchTicketNo);
if (workbenchTicket) {
const params = new URLSearchParams();
params.set("tab", String(workbenchTab));
params.set("ticketNo", workbenchTicket);
if (linkTargetDate) params.set("targetDate", linkTargetDate);
params.set("openDetail", "1");
href = `/doworkbench?${params.toString()}`;
} else {
return <>{code}</>;
}
} else {
return <>{code}</>;
}

return (
<MuiLink
component={Link}
href={href}
underline="hover"
{...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={openInNewTab ? stopGraphEvent : undefined}
onMouseDown={openInNewTab ? stopGraphEvent : undefined}
>
{code}
</MuiLink>
);
};

export default ItemTracingDocLink;

+ 797
- 0
src/components/ItemTracing/ItemTracingFlowGraph.tsx Ver arquivo

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

import "@xyflow/react/dist/style.css";

import {
Background,
BackgroundVariant,
Controls,
MiniMap,
MarkerType,
Panel,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Node,
} from "@xyflow/react";
import {
Box,
Chip,
IconButton,
Paper,
Popover,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import MapOutlinedIcon from "@mui/icons-material/MapOutlined";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import ItemTracingNodeDetailPanel from "./ItemTracingNodeDetailPanel";
import ItemTracingFlowGraphSearch from "./ItemTracingFlowGraphSearch";
import {
buildReactFlowGraph,
reactFlowGraphExtent,
type TraceFlowNodeData,
} from "./buildReactFlowGraph";
import { traceFlowNodeTypes } from "./TraceFlowNodes";
import { traceFlowEdgeTypes } from "./TraceFlowEdge";
import {
VIEWPORT_HEIGHT,
FIT_VIEW_MIN_ZOOM,
FIT_VIEW_MAX_ZOOM,
DO_GROUP_HEADER,
} from "./traceFlowConstants";
import {
minimapNodeColor,
phaseChipColor,
phaseLabelKey,
kindLabelKey,
} from "./traceFlowNodeUtils";
import { searchTraceGraphNodes } from "./traceGraphSearch";
import type { CompiledTraceGraph } from "./compileTraceGraph";
import { TraceGraphNode } from "./buildTraceGraphNodes";
import type { WarehouseFocusRequest } from "./ItemTracing";
import type { TraceGraphPhase } from "./traceGraphLayout";

type TraceParams = {
stockInLineId?: number;
lotNo?: string;
itemCode?: string;
};

type Props = {
data: ItemLotTraceResponse;
compiledGraph: CompiledTraceGraph;
onTrace?: (params: TraceParams) => void;
focusWarehouse?: WarehouseFocusRequest | null;
};

const DETAIL_PANEL_WIDTH = 280;

const LEGEND_ROWS: Array<{ key: string; tipKey: string }> = [
{ key: "flowLegendTime", tipKey: "flowLegendTimeTip" },
{ key: "flowLegendPhase", tipKey: "flowLegendPhaseTip" },
{ key: "flowLegendBranch", tipKey: "flowLegendBranchTip" },
{ key: "flowLegendArrow", tipKey: "flowLegendArrowTip" },
{ key: "flowLegendPrelude", tipKey: "flowLegendPreludeTip" },
];

const ItemTracingFlowGraphInner: React.FC<Props> = ({
data,
compiledGraph,
onTrace,
focusWarehouse,
}) => {
const { t } = useTranslation("itemTracing");
const { fitView } = useReactFlow();
const [selectedNode, setSelectedNode] = useState<TraceGraphNode | null>(null);
const [showMinimap, setShowMinimap] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [activeMatchIndex, setActiveMatchIndex] = useState(0);
const [legendAnchor, setLegendAnchor] = useState<HTMLElement | null>(null);
const [hiddenPhases, setHiddenPhases] = useState<Set<TraceGraphPhase>>(
() => new Set(),
);
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(
() => new Set(),
);

const layout = compiledGraph.layout;
const hasJoPrelude = Boolean(data.joPrelude);

const phaseLabels = useMemo(
() =>
Object.fromEntries(
layout.phaseOrder.map((phase) => [phase, t(phaseLabelKey(phase))]),
),
[layout.phaseOrder, t],
);

const graphElements = useMemo(
() => buildReactFlowGraph(layout, phaseLabels, compiledGraph.edgePairs),
[layout, phaseLabels, compiledGraph.edgePairs],
);

const nodesWithCallbacks = useMemo(
() =>
graphElements.nodes.map((node) =>
node.type === "traceEvent"
? {
...node,
data: { ...node.data, onTrace },
}
: node,
),
[graphElements.nodes, onTrace],
);

const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks);
const [edges, setEdges, onEdgesChange] = useEdgesState(graphElements.edges);

const kindLabel = useCallback(
(kind: TraceGraphNode["kind"]) => t(kindLabelKey(kind)),
[t],
);

const phaseVisible = useCallback(
(phase: TraceGraphPhase | undefined) =>
phase == null || hiddenPhases.size === 0 || !hiddenPhases.has(phase),
[hiddenPhases],
);

const visibleLayoutNodes = useMemo(
() => layout.nodes.filter((n) => phaseVisible(n.phase)),
[layout.nodes, phaseVisible],
);

const matchIds = useMemo(
() => searchTraceGraphNodes(visibleLayoutNodes, searchQuery, kindLabel),
[visibleLayoutNodes, searchQuery, kindLabel],
);

const activeNodeId =
matchIds.length > 0 ? matchIds[activeMatchIndex % matchIds.length] : null;

const focusFitNodes = useMemo(() => {
if (!activeNodeId) return [];
const ids = new Set<string>([activeNodeId]);
const activeRf = graphElements.nodes.find((n) => n.id === activeNodeId);
if (activeRf?.parentId) ids.add(activeRf.parentId);
graphElements.edges.forEach((edge) => {
if (edge.source === activeNodeId) ids.add(edge.target);
if (edge.target === activeNodeId) ids.add(edge.source);
});
return Array.from(ids, (id) => ({ id }));
}, [activeNodeId, graphElements.edges, graphElements.nodes]);

useEffect(() => {
setSearchQuery("");
setActiveMatchIndex(0);
setSelectedNode(null);
setHiddenPhases(new Set());
setCollapsedGroupIds(new Set());
}, [data]);

useEffect(() => {
setActiveMatchIndex(0);
}, [searchQuery]);

const togglePhase = useCallback(
(phase: TraceGraphPhase) => {
setHiddenPhases((prev) => {
const next = new Set(prev);
if (next.has(phase)) next.delete(phase);
else next.add(phase);
// If every phase would be hidden, treat as "show all"
if (next.size >= layout.phaseOrder.length) return new Set();
return next;
});
},
[layout.phaseOrder.length],
);

const toggleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
}, []);

const resetPhaseFilter = useCallback(() => setHiddenPhases(new Set()), []);

const displayedNodes = useMemo(() => {
const hasSearch = searchQuery.trim().length > 0;
const matchSet = new Set(matchIds);
const groupIdsWithChildMatch = new Set<string>();
visibleLayoutNodes.forEach((n) => {
if (n.doGroupId && matchSet.has(n.id))
groupIdsWithChildMatch.add(n.doGroupId);
});
const visibleIds = new Set(visibleLayoutNodes.map((n) => n.id));
layout.nodes.forEach((n) => {
if (n.doGroupId && visibleIds.has(n.id)) visibleIds.add(n.doGroupId);
});

return nodes
.filter((node) => {
if (node.type === "phaseLabel" || node.type === "dateHeader")
return true;
if (node.type === "doGroup" || node.type === "pickGroup") {
return layout.nodes.some(
(n) => n.doGroupId === node.id && phaseVisible(n.phase),
);
}
if (node.type === "traceEvent") {
const layoutNode = (node.data as TraceFlowNodeData | undefined)
?.layoutNode;
if (
layoutNode?.doGroupId &&
collapsedGroupIds.has(layoutNode.doGroupId)
) {
return false;
}
return phaseVisible(layoutNode?.phase);
}
return true;
})
.map((node) => {
if (
node.type !== "traceEvent" &&
node.type !== "doGroup" &&
node.type !== "pickGroup"
) {
return node;
}
const isMatch =
matchSet.has(node.id) ||
((node.type === "doGroup" || node.type === "pickGroup") &&
groupIdsWithChildMatch.has(node.id));
const isFocused = node.id === activeNodeId;
const isSelected = selectedNode?.id === node.id;
const isGroup = node.type === "doGroup" || node.type === "pickGroup";
const groupCollapsed = isGroup && collapsedGroupIds.has(node.id);
const collapsedH = DO_GROUP_HEADER;
return {
...node,
...(groupCollapsed
? {
style: { ...node.style, height: collapsedH },
height: collapsedH,
measured: {
width: node.measured?.width ?? node.width ?? 0,
height: collapsedH,
},
}
: {}),
data: {
...node.data,
searchActive: hasSearch,
searchMatch: isMatch,
searchFocused: isFocused,
nodeSelected: isSelected,
...(isGroup
? {
groupCollapsed,
onToggleGroupCollapse: () => toggleGroupCollapse(node.id),
}
: {}),
},
};
});
}, [
nodes,
searchQuery,
matchIds,
activeNodeId,
selectedNode?.id,
layout.nodes,
visibleLayoutNodes,
phaseVisible,
collapsedGroupIds,
toggleGroupCollapse,
]);

const displayedEdges = useMemo(() => {
const visibleEventIds = new Set(
displayedNodes
.filter(
(n) =>
n.type === "traceEvent" ||
n.type === "doGroup" ||
n.type === "pickGroup",
)
.map((n) => n.id),
);
const phaseFiltered = edges.filter(
(edge) =>
visibleEventIds.has(edge.source) && visibleEventIds.has(edge.target),
);

const hasSearch = searchQuery.trim().length > 0 && matchIds.length > 0;
const selectedId = selectedNode?.id ?? null;
const selectedGroupId = selectedNode?.doGroupId?.trim() || null;
const hasSelection = Boolean(selectedId);
if (!hasSearch && !hasSelection) return phaseFiltered;

const matchSet = hasSearch ? new Set(matchIds) : null;
const selectionIds = new Set<string>();
if (selectedId) {
selectionIds.add(selectedId);
if (selectedGroupId) selectionIds.add(selectedGroupId);
}

const focusStroke = "#1565c0";
const matchStroke = "#ef6c00";
const selectStroke = "#2e7d32";

return phaseFiltered.map((edge) => {
const sourceMatch = matchSet?.has(edge.source) ?? false;
const targetMatch = matchSet?.has(edge.target) ?? false;
const connectsHighlighted =
Boolean(matchSet) && sourceMatch && targetMatch;
const touchesFocus = Boolean(
activeNodeId &&
(edge.source === activeNodeId || edge.target === activeNodeId),
);
const touchesSelection =
hasSelection &&
(selectionIds.has(edge.source) || selectionIds.has(edge.target));
const shouldHighlight =
connectsHighlighted || touchesFocus || touchesSelection;

if (!shouldHighlight) {
return {
...edge,
zIndex: 0,
style: {
...edge.style,
stroke: "#bdbdbd",
strokeWidth: 1,
opacity: 0.18,
},
labelStyle: { ...edge.labelStyle, opacity: 0.2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#bdbdbd",
width: 14,
height: 14,
},
};
}

const stroke = touchesSelection
? selectStroke
: touchesFocus
? focusStroke
: matchStroke;
const strokeWidth = touchesSelection || touchesFocus ? 3 : 2.5;
const zIndex = touchesSelection ? 4 : touchesFocus ? 3 : 2;

return {
...edge,
zIndex,
style: {
...edge.style,
stroke,
strokeWidth,
opacity: 1,
},
labelStyle: {
...edge.labelStyle,
fill: touchesSelection
? selectStroke
: touchesFocus
? focusStroke
: "#e65100",
fontWeight: 700,
opacity: 1,
},
labelBgStyle: {
...edge.labelBgStyle,
fill: touchesSelection
? "#e8f5e9"
: touchesFocus
? "#e3f2fd"
: "#fff8e1",
fillOpacity: 1,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: stroke,
width: touchesSelection || touchesFocus ? 18 : 16,
height: touchesSelection || touchesFocus ? 18 : 16,
},
};
});
}, [
edges,
searchQuery,
matchIds,
activeNodeId,
selectedNode,
displayedNodes,
]);

useEffect(() => {
setNodes(nodesWithCallbacks);
setEdges(graphElements.edges);
const timer = window.setTimeout(() => {
fitView({
padding: 0.04,
minZoom: FIT_VIEW_MIN_ZOOM,
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: 200,
});
}, 50);
return () => window.clearTimeout(timer);
}, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]);

useEffect(() => {
if (!activeNodeId || !searchQuery.trim()) return;
const timer = window.setTimeout(() => {
fitView({
nodes: focusFitNodes,
padding: 0.55,
duration: 280,
maxZoom: 1.25,
});
}, 60);
return () => window.clearTimeout(timer);
}, [activeNodeId, searchQuery, focusFitNodes, fitView]);

useEffect(() => {
if (!focusWarehouse) return;
const { inventoryLotId, warehouseCode } = focusWarehouse;
const locPrefix = `loc-${inventoryLotId}-`;
const wh = warehouseCode.trim().toUpperCase();
const focusIds = layout.nodes
.filter((n) => {
if (n.id.startsWith(locPrefix)) return true;
if (inventoryLotId !== data.lot.inventoryLotId) return false;
if (n.id.startsWith("loc-")) return false;
if (!wh) return true;
return (n.warehouseCode ?? "").trim().toUpperCase() === wh;
})
.map((n) => n.id);
if (focusIds.length === 0) return;
setSearchQuery(warehouseCode.trim() || String(inventoryLotId));
setActiveMatchIndex(0);
const timer = window.setTimeout(() => {
fitView({
nodes: focusIds.map((id) => ({ id })),
padding: 0.2,
duration: 320,
maxZoom: 1.1,
});
}, 80);
return () => window.clearTimeout(timer);
}, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]);

const handleSearchPrev = useCallback(() => {
if (matchIds.length === 0) return;
setActiveMatchIndex(
(prev) => (prev - 1 + matchIds.length) % matchIds.length,
);
}, [matchIds.length]);

const handleSearchNext = useCallback(() => {
if (matchIds.length === 0) return;
setActiveMatchIndex((prev) => (prev + 1) % matchIds.length);
}, [matchIds.length]);

const handleSearchClear = useCallback(() => {
setSearchQuery("");
setActiveMatchIndex(0);
}, []);

const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node<TraceFlowNodeData>) => {
if (node.type === "traceEvent" && node.data?.layoutNode?.id) {
setSelectedNode(node.data.layoutNode);
}
},
[],
);

const translateExtent = useMemo(
() => reactFlowGraphExtent(layout, graphElements.graphHeight),
[layout, graphElements.graphHeight],
);

if (!layout.nodes.length) {
return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography color="text.secondary">{t("noRecords")}</Typography>
</Paper>
);
}

return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
alignItems={{ sm: "center" }}
spacing={1}
sx={{ mb: 1 }}
>
<Typography variant="subtitle1" fontWeight={600} sx={{ flexGrow: 1 }}>
{t("flowGraph")}
</Typography>
<Tooltip title={t("flowLegendOpen")} arrow>
<IconButton
size="small"
aria-label={t("flowLegendOpen")}
onClick={(e) => setLegendAnchor(e.currentTarget)}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>

<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 1 }}
>
{hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")}
{" · "}
{t("flowZoomPanHint")}
</Typography>

<Stack spacing={0.75} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{t("flowPhaseFilter")}
</Typography>
<Stack
direction="row"
flexWrap="wrap"
gap={0.75}
alignItems="center"
justifyContent="center"
>
{layout.phaseOrder.map((phase) => {
const active = phaseVisible(phase);
const chipColor = phaseChipColor(phase);
return (
<Chip
key={phase}
size="small"
label={t(phaseLabelKey(phase))}
color={chipColor}
variant={active ? "filled" : "outlined"}
onClick={() => togglePhase(phase)}
sx={{
borderRadius: 1.5,
height: 28,
fontWeight: 600,
cursor: "pointer",
letterSpacing: 0.1,
opacity: active ? 1 : 0.8,
transition: "box-shadow 0.15s ease, opacity 0.15s ease, filter 0.15s ease",
"& .MuiChip-label": { px: 1.25 },
"&:hover": {
opacity: 1,
boxShadow: 1,
filter: active ? "brightness(1.06)" : undefined,
},
}}
/>
);
})}
<Chip
size="small"
icon={<RestartAltIcon />}
label={t("flowPhaseFilterReset")}
color="error"
variant="filled"
disabled={hiddenPhases.size === 0}
onClick={hiddenPhases.size > 0 ? resetPhaseFilter : undefined}
sx={{
borderRadius: 1.5,
height: 28,
fontWeight: 700,
cursor: hiddenPhases.size === 0 ? "default" : "pointer",
letterSpacing: 0.2,
"& .MuiChip-icon": { ml: 0.5, fontSize: 16 },
"& .MuiChip-label": { px: 1.25 },
"&:hover":
hiddenPhases.size === 0
? undefined
: {
boxShadow: 1,
filter: "brightness(1.08)",
},
}}
/>
</Stack>
</Stack>

<Popover
open={Boolean(legendAnchor)}
anchorEl={legendAnchor}
onClose={() => setLegendAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<Box sx={{ p: 2, maxWidth: 360 }}>
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
{t("flowLegendTitle")}
</Typography>
<Stack spacing={1}>
{LEGEND_ROWS.map(({ key, tipKey }) => (
<Box key={key}>
<Typography variant="body2" fontWeight={600}>
{t(key)}
</Typography>
<Typography variant="caption" color="text.secondary">
{t(tipKey)}
</Typography>
</Box>
))}
{hasJoPrelude && (
<Typography variant="caption" color="text.secondary">
{t("flowGraphPathJo")}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{t("flowNodeDetailHint")}
</Typography>
</Stack>
</Box>
</Popover>

<Box
sx={{
height: VIEWPORT_HEIGHT,
borderRadius: 1,
border: 1,
borderColor: "divider",
overflow: "hidden",
bgcolor: "action.hover",
display: "flex",
"& .react-flow__attribution": { display: "none" },
}}
>
{selectedNode && (
<Box
sx={{ width: DETAIL_PANEL_WIDTH, flexShrink: 0, height: "100%" }}
>
<ItemTracingNodeDetailPanel
node={selectedNode}
onClose={() => setSelectedNode(null)}
/>
</Box>
)}
<Box
sx={{ flex: 1, minWidth: 0, height: "100%", position: "relative" }}
>
<ReactFlow
nodes={displayedNodes}
edges={displayedEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
elevateEdgesOnSelect={false}
defaultEdgeOptions={{ zIndex: 0 }}
nodeTypes={traceFlowNodeTypes}
edgeTypes={traceFlowEdgeTypes}
onNodeClick={onNodeClick}
onPaneClick={() => setSelectedNode(null)}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
panOnScroll
zoomOnScroll
minZoom={0.15}
maxZoom={2.5}
translateExtent={translateExtent}
proOptions={{ hideAttribution: true }}
style={{ width: "100%", height: "100%" }}
>
<Panel position="top-right">
<ItemTracingFlowGraphSearch
query={searchQuery}
matchCount={matchIds.length}
activeIndex={activeMatchIndex}
onQueryChange={setSearchQuery}
onPrev={handleSearchPrev}
onNext={handleSearchNext}
onClear={handleSearchClear}
/>
</Panel>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#bdbdbd"
/>
<Panel position="bottom-left" style={{ left: 12, bottom: 12 }}>
<Stack spacing={0.5} alignItems="center">
<Tooltip
title={
showMinimap ? t("flowMinimapHide") : t("flowMinimapShow")
}
arrow
placement="right"
>
<IconButton
size="small"
onClick={() => setShowMinimap((v) => !v)}
aria-label={
showMinimap ? t("flowMinimapHide") : t("flowMinimapShow")
}
sx={{
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
boxShadow: 1,
borderRadius: 1,
width: 28,
height: 28,
"&:hover": { bgcolor: "background.paper" },
}}
>
<MapOutlinedIcon
fontSize="small"
color={showMinimap ? "primary" : "action"}
/>
</IconButton>
</Tooltip>
<Box
sx={{
"& .react-flow__controls": {
position: "static",
margin: 0,
boxShadow: 1,
},
}}
>
<Controls showInteractive={false} />
</Box>
</Stack>
</Panel>
{showMinimap && (
<MiniMap
nodeColor={(node) => {
if (
node.type !== "traceEvent" &&
node.type !== "doGroup" &&
node.type !== "pickGroup"
) {
return "#e0e0e0";
}
const layoutNode = (
node.data as TraceFlowNodeData | undefined
)?.layoutNode;
return layoutNode?.kind
? minimapNodeColor(layoutNode.kind)
: "#e0e0e0";
}}
pannable
zoomable
style={{ border: "1px solid #e0e0e0", borderRadius: 4 }}
/>
)}
</ReactFlow>
</Box>
</Box>
</Paper>
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 14 | v1.0.0 | 2026-07-17 */
const ItemTracingFlowGraph: React.FC<Props> = (props) => (
<ReactFlowProvider>
<ItemTracingFlowGraphInner {...props} />
</ReactFlowProvider>
);

export default ItemTracingFlowGraph;

+ 149
- 0
src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx Ver arquivo

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

import ClearIcon from "@mui/icons-material/Clear";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import SearchIcon from "@mui/icons-material/Search";
import {
Box,
IconButton,
InputAdornment,
Paper,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";

type Props = {
query: string;
matchCount: number;
activeIndex: number;
onQueryChange: (value: string) => void;
onPrev: () => void;
onNext: () => void;
onClear: () => void;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
const ItemTracingFlowGraphSearch: React.FC<Props> = ({
query,
matchCount,
activeIndex,
onQueryChange,
onPrev,
onNext,
onClear,
}) => {
const { t } = useTranslation("itemTracing");
const trimmed = query.trim();
const hasQuery = trimmed.length > 0;
const matchLabel =
matchCount > 0
? t("flowGraphSearchMatch", { current: activeIndex + 1, total: matchCount })
: hasQuery
? t("flowGraphSearchNoMatch")
: "";

const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.shiftKey) onPrev();
else onNext();
} else if (event.key === "Escape") {
event.preventDefault();
onClear();
}
};

return (
<Paper
elevation={2}
sx={{
p: 1,
minWidth: 280,
maxWidth: 360,
bgcolor: "background.paper",
}}
>
<TextField
size="small"
fullWidth
value={query}
placeholder={t("flowGraphSearchPlaceholder")}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
InputProps={{
startAdornment: (
<InputAdornment position="start" sx={{ mr: 0.5 }}>
<SearchIcon fontSize="small" sx={{ color: "text.secondary" }} />
</InputAdornment>
),
endAdornment: hasQuery ? (
<InputAdornment position="end">
<Tooltip title={t("flowGraphSearchClear")}>
<IconButton size="small" edge="end" onClick={onClear} aria-label={t("flowGraphSearchClear")}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
) : undefined,
}}
sx={{
"& .MuiOutlinedInput-root": {
alignItems: "center",
},
"& .MuiOutlinedInput-input": {
color: "text.secondary",
py: "8.5px",
lineHeight: 1.4375,
},
"& .MuiOutlinedInput-input::placeholder": {
color: "text.disabled",
opacity: 1,
},
"& .MuiInputAdornment-root": {
height: "100%",
maxHeight: "none",
alignItems: "center",
},
}}
/>
{hasQuery && (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mt: 0.75, gap: 1 }}>
<Typography variant="caption" color={matchCount > 0 ? "text.secondary" : "error"} noWrap>
{matchLabel}
</Typography>
<Box sx={{ display: "flex", flexShrink: 0 }}>
<Tooltip title={t("flowGraphSearchPrev")}>
<span>
<IconButton
size="small"
onClick={onPrev}
disabled={matchCount === 0}
aria-label={t("flowGraphSearchPrev")}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("flowGraphSearchNext")}>
<span>
<IconButton
size="small"
onClick={onNext}
disabled={matchCount === 0}
aria-label={t("flowGraphSearchNext")}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
)}
</Paper>
);
};

export default ItemTracingFlowGraphSearch;

+ 11
- 0
src/components/ItemTracing/ItemTracingLoading.tsx Ver arquivo

@@ -0,0 +1,11 @@
import { Skeleton, Stack } from "@mui/material";

const ItemTracingLoading: React.FC = () => (
<Stack spacing={2} sx={{ mt: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
<Skeleton variant="rounded" height={320} />
</Stack>
);

export default ItemTracingLoading;

+ 791
- 0
src/components/ItemTracing/ItemTracingLocations.tsx Ver arquivo

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

import React, { useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Chip,
Stack,
TableContainer,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Inventory2Icon from "@mui/icons-material/Inventory2";
import { useTranslation } from "react-i18next";
import { ItemLotTraceLocationBlock } from "@/app/api/itemTracing";
import type { WarehouseFocusRequest } from "./ItemTracing";
import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";
import { createTraceLabelTranslator } from "./traceLabelUtils";
import {
resolveStockTakeAcceptedQty,
resolveStockTakeBookQty,
} from "./traceStockTakeUtils";
import {
FilterableDataTable,
type FilterableColumnDef,
} from "./itemTracingTableFilters";

interface Props {
locationBlocks: ItemLotTraceLocationBlock[];
onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
}

/** Compact sub-table for sections that may have many rows. */
function SectionTable<T>({
title,
rows,
columns,
getRowKey,
}: {
title: string;
rows: T[];
columns: FilterableColumnDef<T>[];
getRowKey: (row: T, index: number) => string;
}) {
const { t } = useTranslation("itemTracing");
const [showAll, setShowAll] = useState(false);

return (
<Box>
<Typography variant="subtitle2" gutterBottom>
{title} ({rows.length})
</Typography>
<TableContainer>
<FilterableDataTable
rows={rows}
columns={columns}
getRowKey={getRowKey}
emptyLabel={t("noRecords")}
collapseAfter={10}
showAll={showAll}
onToggleShowAll={() => setShowAll((v) => !v)}
showAllLabel={(count) => t("locationsShowAll", { count })}
collapseLabel={(count) => t("locationsCollapse", { count })}
/>
</TableContainer>
</Box>
);
}


/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
const ItemTracingLocations: React.FC<Props> = ({
locationBlocks,
onFocusWarehouse,
}) => {
const { t } = useTranslation("itemTracing");
const tr = createTraceLabelTranslator(t);

if (!locationBlocks || locationBlocks.length === 0) return null;

return (
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" spacing={1}>
<Inventory2Icon fontSize="small" color="primary" />
<Typography variant="subtitle1" fontWeight={600}>
{t("locationHeader", { count: locationBlocks.length })}
</Typography>
<Chip
label={`${locationBlocks.length}`}
size="small"
color="primary"
variant="outlined"
/>
</Stack>

{locationBlocks.map((block) => {
const totalIn = block.warehouseLines.reduce(
(s, l) => s + (l.inQty || 0),
0,
);
const totalOut = block.warehouseLines.reduce(
(s, l) => s + (l.outQty || 0),
0,
);
const available = totalIn - totalOut;
const lastMovement = block.movements[0];
const stockTakeVariance = block.stockTakeEvents.reduce(
(s, st) => s + (st.varianceQty || 0),
0,
);

return (
<Accordion
key={block.inventoryLotId}
slotProps={{ transition: { unmountOnExit: true } }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack
direction="row"
alignItems="center"
spacing={1.5}
flexWrap="wrap"
sx={{ width: "100%", gap: 0.5 }}
>
<Typography variant="body2" fontWeight={600}>
{block.warehouseLines
.map((l) => l.warehouseCode)
.filter(Boolean)
.join(" / ") || `Lot #${block.inventoryLotId}`}
</Typography>
{block.itemName && (
<Typography variant="caption" color="text.secondary">
{block.itemCode} ??{block.itemName}
</Typography>
)}
<Chip
label={`${t("available")}: ${available}`}
size="small"
color={available > 0 ? "success" : "default"}
variant="outlined"
/>
<Typography variant="caption" color="text.secondary">
{t("inQty")}: {totalIn} / {t("outQty")}: {totalOut}
</Typography>
{block.movements.length > 0 && (
<Chip
label={`${block.movements.length} moves`}
size="small"
variant="outlined"
/>
)}
{lastMovement?.timestamp && (
<Typography variant="caption" color="text.secondary">
{t("lastMove")}: {lastMovement.timestamp}
</Typography>
)}
{stockTakeVariance !== 0 && (
<Chip
label={`? ${stockTakeVariance}`}
size="small"
color={stockTakeVariance !== 0 ? "warning" : "default"}
variant="outlined"
/>
)}
{onFocusWarehouse ? (
<Chip
label={t("focusWarehouseInGraph")}
size="small"
color="primary"
variant="filled"
clickable
onClick={(e) => {
e.stopPropagation();
onFocusWarehouse({
inventoryLotId: block.inventoryLotId,
warehouseCode: blockWarehouseCode(block),
});
}}
sx={{ ml: "auto" }}
/>
) : null}
</Stack>
</AccordionSummary>

<AccordionDetails>
<Stack spacing={2}>
{block.warehouseLines.length > 0 && (
<SectionTable
title={t("warehouseLines")}
rows={block.warehouseLines}
getRowKey={(wl) => String(wl.inventoryLotLineId)}
columns={[
{
key: "warehouse",
label: t("warehouse"),
value: (wl) => wl.warehouseCode,
},
{
key: "inQty",
label: t("inQty"),
align: "right",
value: (wl) => wl.inQty,
},
{
key: "outQty",
label: t("outQty"),
align: "right",
value: (wl) => wl.outQty,
},
{
key: "available",
label: t("available"),
align: "right",
value: (wl) => wl.availableQty,
},
{
key: "status",
label: t("status"),
value: (wl) => tr.lotLineStatus(wl.status),
},
]}
/>
)}

{block.origins.length > 0 && (
<SectionTable
title={t("origins")}
rows={block.origins}
getRowKey={(o) => String(o.stockInLineId)}
columns={[
{
key: "type",
label: t("type"),
value: (o) => o.type,
cell: (o) => (
<Chip
label={o.type}
size="small"
variant="outlined"
/>
),
},
{
key: "refCode",
label: t("refCode"),
value: (o) => o.refCode,
},
{
key: "supplier",
label: t("supplier"),
value: (o) => o.supplierName || o.supplierCode,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (o) => o.acceptedQty,
},
{
key: "date",
label: t("date"),
value: (o) => o.receiptDate,
},
]}
/>
)}

{block.qcResults.length > 0 && (
<SectionTable
title={t("tabQc")}
rows={block.qcResults}
getRowKey={(qc) => `${qc.stockInLineId}-${qc.created}`}
columns={[
{
key: "qcPassed",
label: t("qcPassed"),
value: (qc) =>
qc.qcPassed ? t("qcPassed") : t("qcFailed"),
cell: (qc) => (
<Chip
label={qc.qcPassed ? t("qcPassed") : t("qcFailed")}
size="small"
color={qc.qcPassed ? "success" : "error"}
variant="outlined"
/>
),
},
{
key: "acceptedQty",
label: t("acceptedQty"),
align: "right",
value: (qc) => qc.acceptedQty,
},
{
key: "failQty",
label: t("failQty"),
align: "right",
value: (qc) => qc.failQty,
},
{
key: "qcType",
label: t("detailQcType"),
value: (qc) => qc.qcType,
},
{
key: "handler",
label: t("handler"),
value: (qc) => qc.handledBy,
},
{
key: "date",
label: t("date"),
value: (qc) => qc.created,
},
]}
/>
)}

{block.purchaseEvents && block.purchaseEvents.length > 0 && (
<SectionTable
title={t("categoryPurchase")}
rows={block.purchaseEvents}
getRowKey={(pe) => String(pe.purchaseOrderLineId)}
columns={[
{
key: "po",
label: t("detailPurchaseOrderNo"),
value: (pe) => pe.purchaseOrderCode,
},
{
key: "supplier",
label: t("supplier"),
value: (pe) => pe.supplierName || pe.supplierCode,
},
{
key: "orderQty",
label: t("detailOrderQty"),
align: "right",
value: (pe) => pe.orderQty,
},
{
key: "putAwayQty",
label: t("detailPutAwayQty"),
align: "right",
value: (pe) => pe.putAwayQty,
},
{
key: "date",
label: t("date"),
value: (pe) => pe.orderDate,
},
]}
/>
)}

{block.putawayEvents.length > 0 && (
<SectionTable
title={t("nodePutaway")}
rows={block.putawayEvents}
getRowKey={(pa, idx) => `${pa.inventoryLotLineId}-${idx}`}
columns={[
{
key: "warehouse",
label: t("warehouse"),
value: (pa) => pa.warehouseCode,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (pa) => pa.qty,
},
{
key: "handler",
label: t("handler"),
value: (pa) => pa.handledBy,
},
{
key: "refCode",
label: t("refCode"),
value: (pa) => pa.refCode,
},
{
key: "date",
label: t("date"),
value: (pa) => pa.timestamp,
},
]}
/>
)}

{block.movements.length > 0 && (
<SectionTable
title={t("movements")}
rows={block.movements}
getRowKey={(m, idx) => `${m.refCode}-${m.timestamp}-${idx}`}
columns={[
{
key: "direction",
label: t("direction"),
value: (m) => m.direction,
cell: (m) => (
<Chip
label={m.direction}
size="small"
color={m.direction === "IN" ? "success" : "error"}
variant="outlined"
/>
),
},
{
key: "type",
label: t("type"),
value: (m) => m.refType,
},
{
key: "refCode",
label: t("refCode"),
value: (m) => m.refCode,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (m) => m.qty,
},
{
key: "warehouse",
label: t("warehouse"),
value: (m) => m.warehouseCode,
},
{
key: "date",
label: t("date"),
value: (m) => m.timestamp,
},
]}
/>
)}

{block.outboundUsage.length > 0 && (
<SectionTable
title={t("outbound")}
rows={block.outboundUsage}
getRowKey={(o, idx) => `${o.stockOutLineId}-${idx}`}
columns={[
{
key: "pickOrder",
label: t("pickOrder"),
value: (o) => o.pickOrderCode || o.deliveryOrderCode,
},
{
key: "type",
label: t("type"),
value: (o) => tr.usageType(o.usageType),
cell: (o) => (
<Chip
label={tr.usageType(o.usageType)}
size="small"
variant="outlined"
/>
),
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (o) => o.qty,
},
{
key: "handler",
label: t("handler"),
value: (o) => o.handler,
},
{
key: "date",
label: t("date"),
value: (o) => o.timestamp,
},
]}
/>
)}

{block.stockTakeEvents.length > 0 && (
<SectionTable
title={t("stockTake")}
rows={block.stockTakeEvents}
getRowKey={(st, idx) => `${st.stockTakeCode}-${idx}`}
columns={[
{
key: "code",
label: t("stockTakeCode"),
value: (st) => st.stockTakeCode,
},
{
key: "section",
label: t("section"),
value: (st) => st.stockTakeSection,
},
{
key: "round",
label: t("round"),
value: (st) => st.stockTakeRoundName,
},
{
key: "before",
label: t("before"),
align: "right",
value: (st) =>
resolveStockTakeBookQty(st.recordDetail, st.beforeQty),
},
{
key: "after",
label: t("after"),
align: "right",
value: (st) =>
resolveStockTakeAcceptedQty(
st.recordDetail,
st.afterQty,
),
},
{
key: "variance",
label: t("variance"),
align: "right",
value: (st) =>
st.recordDetail?.varianceQty != null
? st.recordDetail.varianceQty
: st.varianceQty,
},
{
key: "date",
label: t("date"),
value: (st) => st.timestamp,
},
]}
/>
)}

{block.adjustments.length > 0 && (
<SectionTable
title={t("tabAdjustments")}
rows={block.adjustments}
getRowKey={(adj, idx) =>
`${adj.refCode}-${adj.timestamp}-${idx}`
}
columns={[
{
key: "type",
label: t("type"),
value: (adj) => adj.adjustmentType,
},
{
key: "direction",
label: t("direction"),
value: (adj) => adj.direction,
cell: (adj) => (
<Chip
label={adj.direction}
size="small"
color={adj.direction === "IN" ? "success" : "error"}
variant="outlined"
/>
),
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (adj) => adj.qty,
},
{
key: "remarks",
label: t("remarks"),
value: (adj) => adj.reason,
},
{
key: "handler",
label: t("handler"),
value: (adj) => adj.handledBy,
},
{
key: "date",
label: t("date"),
value: (adj) => adj.timestamp,
},
]}
/>
)}

{block.transfers.length > 0 && (
<SectionTable
title={t("tabTransfers")}
rows={block.transfers}
getRowKey={(xfer, idx) => `${xfer.transferCode}-${idx}`}
columns={[
{
key: "from",
label: t("from"),
value: (xfer) => xfer.fromWarehouse,
},
{
key: "to",
label: t("to"),
value: (xfer) => xfer.toWarehouse,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (xfer) => xfer.qty,
},
{
key: "refCode",
label: t("refCode"),
value: (xfer) => xfer.transferCode,
},
{
key: "date",
label: t("date"),
value: (xfer) => xfer.timestamp,
},
]}
/>
)}

{block.doDeliveries.length > 0 && (
<SectionTable
title={t("deliveryOrder")}
rows={block.doDeliveries}
getRowKey={(dd, idx) => `${dd.stockOutLineId}-${idx}`}
columns={[
{
key: "pickOrder",
label: t("pickOrder"),
value: (dd) => dd.pickOrderCode,
},
{
key: "deliveryOrder",
label: t("deliveryOrder"),
value: (dd) => dd.deliveryOrderCode,
},
{
key: "deliveryNoteCode",
label: t("deliveryNoteCode"),
value: (dd) => dd.deliveryNoteCode || "—",
},
{
key: "ticketNo",
label: t("ticketNo"),
value: (dd) => dd.ticketNo || "—",
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (dd) => dd.qty,
},
{
key: "handler",
label: t("handler"),
value: (dd) => dd.handler,
},
{
key: "date",
label: t("date"),
value: (dd) => dd.timestamp,
},
]}
/>
)}

{block.failEvents.length > 0 && (
<SectionTable
title={t("nodeFail")}
rows={block.failEvents}
getRowKey={(fe) => String(fe.failId)}
columns={[
{
key: "category",
label: t("detailFailCategory"),
value: (fe) => `${fe.failType} / ${fe.category}`,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (fe) => fe.qty,
},
{
key: "pickOrder",
label: t("pickOrder"),
value: (fe) => fe.pickOrderCode,
},
{
key: "handler",
label: t("handler"),
value: (fe) => fe.handlerName,
},
{
key: "date",
label: t("date"),
value: (fe) => fe.recordDate,
},
]}
/>
)}

{block.returnEvents.length > 0 && (
<SectionTable
title={t("nodeReturn")}
rows={block.returnEvents}
getRowKey={(re, idx) => `${re.stockOutLineId}-${idx}`}
columns={[
{
key: "type",
label: t("type"),
value: (re) => re.movementType,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (re) => re.qty,
},
{
key: "warehouse",
label: t("warehouse"),
value: (re) => re.warehouseCode,
},
{
key: "remarks",
label: t("remarks"),
value: (re) => re.remarks,
},
{
key: "date",
label: t("date"),
value: (re) => re.timestamp,
},
]}
/>
)}

{block.openMovements.length > 0 && (
<SectionTable
title={t("nodeOpen")}
rows={block.openMovements}
getRowKey={(om) => String(om.stockInLineId)}
columns={[
{
key: "warehouse",
label: t("warehouse"),
value: (om) => om.warehouseCode,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (om) => om.qty,
},
{
key: "refCode",
label: t("refCode"),
value: (om) => om.refCode,
},
{
key: "handler",
label: t("handler"),
value: (om) => om.handledBy,
},
{
key: "date",
label: t("date"),
value: (om) => om.timestamp,
},
]}
/>
)}
</Stack>
</AccordionDetails>
</Accordion>
);
})}
</Stack>
);
};

export default ItemTracingLocations;

+ 50
- 0
src/components/ItemTracing/ItemTracingLotTraceLink.tsx Ver arquivo

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

import Link from "next/link";
import MuiLink, { type LinkProps as MuiLinkProps } from "@mui/material/Link";
import { buildItemTracingHref } from "./traceNavigationUtils";

type Props = {
label: string;
lotNo?: string;
itemCode?: string;
stockInLineId?: number;
/** Prevent React Flow node selection when the link sits on a graph card. */
stopPropagation?: boolean;
variant?: MuiLinkProps["variant"];
sx?: MuiLinkProps["sx"];
};

const stopGraphEvent = (e: React.MouseEvent) => {
e.stopPropagation();
};

const ItemTracingLotTraceLink: React.FC<Props> = ({
label,
lotNo,
itemCode,
stockInLineId,
stopPropagation = false,
variant = "caption",
sx,
}) => {
if (!lotNo?.trim() && stockInLineId == null) return null;

return (
<MuiLink
component={Link}
href={buildItemTracingHref({ lotNo, itemCode, stockInLineId })}
underline="hover"
target="_blank"
rel="noopener noreferrer"
variant={variant}
sx={sx}
onClick={stopPropagation ? stopGraphEvent : undefined}
onMouseDown={stopPropagation ? stopGraphEvent : undefined}
>
{label}
</MuiLink>
);
};

export default ItemTracingLotTraceLink;

+ 265
- 0
src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx Ver arquivo

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

import {
Box,
Chip,
Divider,
IconButton,
List,
ListItem,
ListItemText,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableRow,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useTranslation } from "react-i18next";
import { TraceGraphNode } from "./buildTraceGraphNodes";
import ItemTracingDocLink from "./ItemTracingDocLink";
import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle";
import { formatQty } from "./traceQtyUtils";
import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils";

type Props = {
node: TraceGraphNode;
onClose?: () => void;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
const ItemTracingNodeDetailPanel: React.FC<Props> = ({ node, onClose }) => {
const { t } = useTranslation("itemTracing");

const hasDocLink = node.docLinkKind && node.refCode;
const lifecycleStages =
node.kind === "STOCK_TAKE" && node.stockTakeRecordDetail
? buildStockTakeLifecycleStages(node.stockTakeRecordDetail)
: [];
const activeStageCount = lifecycleStages.filter((s) => s.isActive).length;

return (
<Paper
elevation={0}
square
sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
borderRight: 1,
borderColor: "divider",
overflow: "hidden",
}}
>
<Box
sx={{
px: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
flexShrink: 0,
}}
>
<Stack direction="row" spacing={0.5} alignItems="flex-start" justifyContent="space-between">
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={700}>
{t("nodeDetailTitle")}
</Typography>
{node.categoryLabel && (
<Chip size="small" label={node.categoryLabel} color="primary" variant="outlined" />
)}
</Stack>
{onClose && (
<IconButton size="small" onClick={onClose} aria-label={t("nodeDetailClose")} sx={{ mt: -0.5 }}>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Stack>
</Box>

<Box sx={{ flex: 1, overflow: "auto", px: 1.5, py: 1.25 }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
{hasDocLink ? (
<ItemTracingDocLink
kind={node.docLinkKind!}
code={node.refCode!}
id={node.refId}
consoCode={node.consoCode ?? node.refCode}
ticketNo={node.docLinkTicketNo}
targetDate={node.docLinkTargetDate}
openInNewTab
/>
) : (
node.title
)}
</Typography>
{node.subtitle && (
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
{node.subtitle}
</Typography>
)}

<Table size="small" sx={{ mt: 0.5 }}>
<TableBody>
{node.details.map((row, i) => (
<TableRow key={`${row.label}-${i}`}>
<TableCell
component="th"
scope="row"
sx={{
width: "42%",
fontWeight: 600,
color: "text.secondary",
border: 0,
py: 0.5,
pl: 0,
fontSize: "0.75rem",
verticalAlign: "top",
}}
>
{row.label}
</TableCell>
<TableCell
sx={{
border: 0,
py: 0.5,
pr: 0,
fontSize: "0.8125rem",
whiteSpace: "pre-line",
verticalAlign: "top",
}}
>
{row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? (
<List dense disablePadding sx={{ width: "100%" }}>
{row.qcCriteriaItems.map((item, idx) => (
<ListItem
key={`${item.name}-${idx}`}
disableGutters
alignItems="flex-start"
sx={{
py: 0.5,
borderBottom:
idx < row.qcCriteriaItems!.length - 1 ? "1px solid" : "none",
borderColor: "divider",
}}
>
<ListItemText
primary={
<Stack
direction="row"
spacing={0.5}
alignItems="center"
flexWrap="wrap"
useFlexGap
>
<Typography variant="caption" fontWeight={600} component="span">
{item.name}
</Typography>
<Chip
size="small"
label={item.passed ? t("qcPassed") : t("qcFailed")}
color={item.passed ? "success" : "error"}
variant="outlined"
sx={{ height: 20, "& .MuiChip-label": { px: 0.75, fontSize: "0.65rem" } }}
/>
{!item.passed && item.failQty != null && item.failQty > 0 && (
<Typography variant="caption" color="error.main" component="span">
{t("failQty")}: {formatQty(item.failQty, node.uom)}
</Typography>
)}
</Stack>
}
secondary={
item.description ? (
<Typography
variant="caption"
color="text.secondary"
component="span"
sx={{ display: "block", mt: 0.25, lineHeight: 1.4 }}
>
{item.description}
</Typography>
) : null
}
/>
</ListItem>
))}
</List>
) : row.linkKind && row.linkCode ? (
<ItemTracingDocLink
kind={row.linkKind}
code={row.linkCode}
id={row.linkId}
consoCode={row.consoCode ?? row.linkCode}
ticketNo={row.linkTicketNo}
targetDate={row.linkTargetDate}
openInNewTab
/>
) : (
<Typography
component="span"
sx={{
fontSize: "inherit",
fontWeight: row.valueColor && row.valueColor !== "default" ? 700 : undefined,
color:
row.valueColor && row.valueColor !== "default"
? `${row.valueColor}.main`
: undefined,
}}
>
{row.value || "—"}
</Typography>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>

{node.traceLotNo ? (
<Box sx={{ mt: 1.5 }}>
<ItemTracingLotTraceLink
label={
node.kind === "BYPRODUCT"
? t("traceByproductLot")
: node.kind === "REPACK"
? t("traceRepackLot")
: t("traceMaterialLot")
}
lotNo={node.traceLotNo}
itemCode={node.traceItemCode}
variant="body2"
sx={{ display: "inline-block" }}
/>
</Box>
) : null}

{lifecycleStages.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="subtitle2" fontWeight={700} gutterBottom>
{t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })}
</Typography>
<ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} />
</>
)}

{node.meta && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary">
{node.meta}
</Typography>
</>
)}
</Box>
</Paper>
);
};

export default ItemTracingNodeDetailPanel;

+ 142
- 0
src/components/ItemTracing/ItemTracingScanBar.tsx Ver arquivo

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

import {
Alert,
Button,
Chip,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner";
import SearchIcon from "@mui/icons-material/Search";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";

type ScanBarProps = {
onTrace: (params: {
stockInLineId?: number;
lotNo?: string;
itemCode?: string;
}) => void;
loading: boolean;
lastLotNo?: string;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 12 | v1.0.0 | 2026-07-17 */
const ItemTracingScanBar: React.FC<ScanBarProps> = ({
onTrace,
loading,
lastLotNo,
}) => {
const { t } = useTranslation("itemTracing");
const scanner = useQrCodeScannerContext();
const [scanMode, setScanMode] = useState<"idle" | "wedge">("idle");
const [itemCode, setItemCode] = useState("");
const [lotNo, setLotNo] = useState("");
const [scanError, setScanError] = useState<string | null>(null);

const startWedgeScan = useCallback(() => {
setScanError(null);
setScanMode("wedge");
scanner.resetScan();
scanner.startScan();
}, [scanner]);

const stopWedgeScan = useCallback(() => {
scanner.stopScan();
scanner.resetScan();
setScanMode("idle");
}, [scanner]);

useEffect(() => {
if (scanMode !== "wedge") return;
const itemId = scanner.result?.itemId;
const stockInLineId = scanner.result?.stockInLineId;
if (!itemId || !stockInLineId) return;
setScanError(null);
stopWedgeScan();
onTrace({ stockInLineId: Number(stockInLineId) });
}, [scanMode, scanner.result, onTrace, stopWedgeScan]);

const handleManualSearch = () => {
const code = itemCode.trim();
const lot = lotNo.trim();
if (!code || !lot) return;
onTrace({ itemCode: code, lotNo: lot });
};

return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Stack spacing={2}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
alignItems={{ sm: "center" }}
>
<Button
variant={scanMode === "wedge" ? "contained" : "outlined"}
startIcon={<QrCodeScannerIcon />}
onClick={scanMode === "wedge" ? stopWedgeScan : startWedgeScan}
disabled={loading}
>
{scanMode === "wedge" ? t("scanning") : t("scanAgain")}
</Button>
{lastLotNo && (
<Chip
label={lastLotNo}
color="primary"
variant="outlined"
sx={{ ml: { sm: "auto" } }}
/>
)}
</Stack>

{scanError && (
<Alert severity="warning" onClose={() => setScanError(null)}>
{scanError}
</Alert>
)}

{scanMode === "wedge" && (
<Alert severity="info" onClose={stopWedgeScan}>
{t("scanReady")}
</Alert>
)}

<Typography variant="subtitle2">{t("manualSearch")}</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
size="small"
label={t("itemCode")}
value={itemCode}
onChange={(e) => setItemCode(e.target.value)}
disabled={loading}
fullWidth
/>
<TextField
size="small"
label={t("lotNo")}
value={lotNo}
onChange={(e) => setLotNo(e.target.value)}
disabled={loading}
fullWidth
/>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={handleManualSearch}
disabled={loading || !itemCode.trim() || !lotNo.trim()}
sx={{ minWidth: 120 }}
>
{loading ? t("searching") : t("search")}
</Button>
</Stack>
</Stack>
</Paper>
);
};

export default ItemTracingScanBar;

+ 910
- 0
src/components/ItemTracing/ItemTracingSections.tsx Ver arquivo

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

import {
Box,
Chip,
Checkbox,
Link as MuiLink,
Paper,
Tab,
Tabs,
Typography,
} from "@mui/material";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import ItemTracingDocLink from "./ItemTracingDocLink";
import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
import { createTraceLabelTranslator, qcItemLabel } from "./traceLabelUtils";
import { formatNum, formatQty } from "./traceQtyUtils";
import { normalizeTargetDateForLink } from "./traceDocLinkUtils";
import type { CompiledTraceGraph } from "./compileTraceGraph";
import { buildTracePresentationRows } from "./tracePresentationAdapter";
import {
hasMultipleLocations,
mergeScopedAdjustments,
mergeScopedOrigins,
mergeScopedOutboundUsage,
mergeScopedQcResults,
mergeScopedStockTakeEvents,
mergeScopedTransfers,
} from "./mergeLocationScopedData";
import {
resolveStockTakeAcceptedQty,
resolveStockTakeBookQty,
} from "./traceStockTakeUtils";
import {
FilterableDataTable,
type FilterableColumnDef,
} from "./itemTracingTableFilters";

type TraceParams = { stockInLineId?: number; lotNo?: string; itemCode?: string };

type Props = {
data: ItemLotTraceResponse;
compiledGraph: CompiledTraceGraph;
onTrace?: (params: TraceParams) => void;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.1 | 2026-07-20 */
const ItemTracingSections: React.FC<Props> = ({ data, compiledGraph, onTrace }) => {
const { t } = useTranslation("itemTracing");
const [tab, setTab] = useState(0);
const { bomTrace, joPrelude, lot } = data;
const tr = useMemo(() => createTraceLabelTranslator(t), [t]);

const presentationRows = useMemo(
() => buildTracePresentationRows(compiledGraph.nodes, data),
[compiledGraph.nodes, data],
);

const multiLocation = hasMultipleLocations(data);
const mergedOrigins = useMemo(() => mergeScopedOrigins(data), [data]);
const mergedQc = useMemo(() => mergeScopedQcResults(data), [data]);
const mergedOutbound = useMemo(() => mergeScopedOutboundUsage(data), [data]);
const mergedStockTake = useMemo(() => mergeScopedStockTakeEvents(data), [data]);
const mergedAdjustments = useMemo(() => mergeScopedAdjustments(data), [data]);
const mergedTransfers = useMemo(() => mergeScopedTransfers(data), [data]);

const stockUom = lot.uom;

const tabKeys = useMemo(() => {
const keys = [
"origins",
"qc",
"outbound",
"stockTake",
"adjustments",
"transfers",
"bom",
] as const;
if (joPrelude) {
return [...keys, "joPick"] as const;
}
return keys;
}, [joPrelude]);

const activeTab = tabKeys[tab] ?? tabKeys[0];

const bomDirectionLabel =
bomTrace.direction === "FINISHED_GOOD"
? t("bomDirectionFG")
: bomTrace.direction === "MATERIAL"
? t("bomDirectionMaterial")
: t("bomDirectionUnknown");

const useJoMaterialInputs = joPrelude != null && joPrelude.materialInputs.length > 0;

const checkboxCellFromStatus = (
status?: string | null,
opts?: { checkedWhen?: Array<string>; rejectedWhen?: Array<string> },
): React.ReactNode => {
const s = String(status || "").trim().toLowerCase();
const checkedWhen = opts?.checkedWhen ?? [];
const rejectedWhen = opts?.rejectedWhen ?? [];

const isRejected = rejectedWhen.includes(s);
const isChecked = checkedWhen.includes(s);

if (isRejected) {
return (
<Checkbox
checked
disabled
readOnly
size="small"
sx={{ color: "error.main", "&.Mui-checked": { color: "error.main" } }}
/>
);
}
if (isChecked) {
return (
<Checkbox
checked
disabled
readOnly
size="small"
sx={{ color: "success.main", "&.Mui-checked": { color: "success.main" } }}
/>
);
}
return <Checkbox checked={false} disabled readOnly size="small" />;
};

const originCols = useMemo((): FilterableColumnDef<(typeof mergedOrigins)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedOrigins)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (o) => o.scopeWarehouseCode,
});
}
cols.push(
{
key: "type",
label: t("type"),
value: (o) => tr.refType(o.type),
},
{
key: "ref",
label: t("ref"),
value: (o) => o.refCode || "—",
cell: (o) =>
o.type === "PO" ? (
<ItemTracingDocLink kind="po" code={o.refCode} id={o.refId} />
) : o.type === "JO" ? (
<ItemTracingDocLink kind="jo" code={o.refCode} id={o.refId} />
) : (
o.refCode || "—"
),
},
{
key: "supplier",
label: t("supplier"),
value: (o) => [o.supplierCode, o.supplierName].filter(Boolean).join(" "),
},
{ key: "dnNo", label: t("dnNo"), value: (o) => o.dnNo || "—" },
{
key: "qty",
label: t("qty"),
align: "right",
value: (o) => formatQty(o.acceptedQty, stockUom),
},
{
key: "status",
label: t("status"),
value: (o) => tr.stockInStatus(o.status),
},
{
key: "timestamp",
label: t("timestamp"),
value: (o) => o.receiptDate ?? "—",
},
);
return cols;
}, [multiLocation, t, tr, stockUom]);

const qcCols = useMemo((): FilterableColumnDef<(typeof mergedQc)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedQc)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (q) => q.scopeWarehouseCode,
});
}
cols.push(
{
key: "status",
label: t("status"),
value: (q) => (q.qcPassed ? t("qcPassed") : t("qcFailed")),
cell: (q) => (
<Chip
size="small"
color={q.qcPassed ? "success" : "error"}
label={q.qcPassed ? t("qcPassed") : t("qcFailed")}
/>
),
},
{
key: "criteria",
label: t("detailQcCriteria"),
value: (q) => qcItemLabel(q) || "—",
},
{
key: "acceptedQty",
label: t("acceptedQty"),
align: "right",
value: (q) => formatQty(q.acceptedQty, stockUom),
},
{
key: "failQty",
label: t("failQty"),
align: "right",
value: (q) => formatQty(q.failQty, stockUom),
},
{
key: "remarks",
label: t("remarks"),
value: (q) => q.remarks?.trim() || "",
},
{
key: "handler",
label: t("handler"),
value: (q) => q.handledBy || "—",
},
{
key: "timestamp",
label: t("timestamp"),
value: (q) => q.created ?? "—",
},
);
return cols;
}, [multiLocation, t, stockUom]);

const outboundCols = useMemo((): FilterableColumnDef<(typeof mergedOutbound)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedOutbound)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (u) => u.scopeWarehouseCode,
});
}
cols.push(
{
key: "type",
label: t("type"),
value: (u) => tr.usageType(u.usageType),
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (u) => formatQty(u.qty, stockUom),
},
{
key: "pickOrder",
label: t("pickOrder"),
value: (u) => u.pickOrderCode || u.consoCode || "—",
cell: (u) => (
<ItemTracingDocLink
kind="pick"
code={u.pickOrderCode}
id={u.pickOrderId}
consoCode={u.consoCode}
/>
),
},
{
key: "jobOrder",
label: t("jobOrder"),
value: (u) => u.jobOrderCode || "—",
cell: (u) => (
<ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} />
),
},
{
key: "deliveryOrder",
label: t("deliveryOrder"),
value: (u) => u.deliveryOrderCode || "—",
},
{
key: "handler",
label: t("handler"),
value: (u) => u.handler || "—",
},
{
key: "timestamp",
label: t("timestamp"),
value: (u) => u.timestamp ?? "—",
},
);
return cols;
}, [multiLocation, t, tr, stockUom]);

const stockTakeCols = useMemo((): FilterableColumnDef<(typeof mergedStockTake)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedStockTake)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (e) => e.scopeWarehouseCode,
});
}
cols.push(
{ key: "ref", label: t("ref"), value: (e) => e.stockTakeCode },
{
key: "round",
label: t("detailStockTakeRound"),
value: (e) =>
e.stockTakeRoundName?.trim() ||
(e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : "—"),
},
{
key: "section",
label: t("detailStockTakeSection"),
value: (e) => e.stockTakeSection?.trim() || "—",
},
{
key: "location",
label: t("detailLocation"),
value: (e) => e.warehouseCode?.trim() || "—",
},
{
key: "lotNo",
label: t("lotNo"),
value: (e) => e.lotNo?.trim() || data.lot.lotNo || "—",
},
{
key: "before",
label: t("before"),
align: "right",
value: (e) =>
formatQty(
resolveStockTakeBookQty(e.recordDetail, e.beforeQty),
stockUom,
),
},
{
key: "after",
label: t("after"),
align: "right",
value: (e) =>
formatQty(
resolveStockTakeAcceptedQty(e.recordDetail, e.afterQty),
stockUom,
),
},
{
key: "variance",
label: t("variance"),
align: "right",
value: (e) =>
formatQty(
e.recordDetail?.varianceQty != null
? e.recordDetail.varianceQty
: e.varianceQty,
stockUom,
),
},
{
key: "approver",
label: t("approver"),
value: (e) => e.approver || "—",
},
{
key: "timestamp",
label: t("timestamp"),
value: (e) => e.timestamp ?? "—",
},
);
return cols;
}, [multiLocation, t, stockUom, data.lot.lotNo]);

const adjustmentCols = useMemo((): FilterableColumnDef<(typeof mergedAdjustments)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedAdjustments)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (a) => a.scopeWarehouseCode,
});
}
cols.push(
{
key: "type",
label: t("type"),
value: (a) => a.adjustmentType,
},
{
key: "direction",
label: `${t("directionIn")}/${t("directionOut")}`,
value: (a) => a.direction,
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (a) => formatQty(a.qty, stockUom),
},
{
key: "ref",
label: t("ref"),
value: (a) => a.refCode || "—",
},
{
key: "remarks",
label: t("remarks"),
value: (a) => a.reason?.trim() || "",
},
{
key: "handler",
label: t("handler"),
value: (a) => a.handledBy || "—",
},
{
key: "timestamp",
label: t("timestamp"),
value: (a) => a.timestamp ?? "—",
},
);
return cols;
}, [multiLocation, t, stockUom]);

const transferCols = useMemo((): FilterableColumnDef<(typeof mergedTransfers)[number]>[] => {
const cols: FilterableColumnDef<(typeof mergedTransfers)[number]>[] = [];
if (multiLocation) {
cols.push({
key: "warehouse",
label: t("warehouse"),
value: (row) => row.scopeWarehouseCode,
});
}
cols.push(
{
key: "from",
label: t("from"),
value: (row) => row.fromWarehouse || "—",
},
{
key: "to",
label: t("to"),
value: (row) => row.toWarehouse || "—",
},
{
key: "qty",
label: t("qty"),
align: "right",
value: (row) => formatQty(row.qty, stockUom),
},
{
key: "timestamp",
label: t("timestamp"),
value: (row) => row.timestamp ?? "—",
},
);
return cols;
}, [multiLocation, t, stockUom]);

type JoMaterialInput = NonNullable<typeof joPrelude>["materialInputs"][number];
type BomUpstream = (typeof bomTrace.upstream)[number];

const bomUpstreamJoCols = useMemo((): FilterableColumnDef<JoMaterialInput>[] => [
{
key: "jobOrder",
label: t("jobOrder"),
value: (m) => m.jobOrderCode || "—",
cell: (m) => (
<ItemTracingDocLink kind="jo" code={m.jobOrderCode} id={m.jobOrderId} />
),
},
{ key: "material", label: t("itemCode"), value: (m) => m.materialItemCode },
{ key: "materialLot", label: t("itemLot"), value: (m) => m.materialLotNo },
{
key: "pickOrder",
label: t("pickOrder"),
value: (m) => m.pickOrderCode || m.consoCode || "—",
cell: (m) =>
m.pickOrderCode ? (
<ItemTracingDocLink
kind="pick"
code={m.pickOrderCode}
id={m.pickOrderId}
consoCode={m.consoCode}
/>
) : (
"—"
),
},
{
key: "pickedAt",
label: t("pickedAt"),
value: (m) => m.pickedAt ?? "—",
},
{
key: "materialQty",
label: t("materialQty"),
align: "right",
value: (m) => formatNum(Number(m.materialQty)),
},
{
key: "qtyPerUnit",
label: t("qtyPerUnit"),
align: "right",
value: (m) =>
m.bomQtyPerUnit != null ? formatNum(Number(m.bomQtyPerUnit)) : "—",
},
{
key: "uom",
label: t("uom"),
value: (m) => m.materialUom?.trim() || "",
},
{
key: "processingStatus",
label: t("processingStatus"),
align: "center",
value: (m) => tr.processingStatus(m.processingStatus),
cell: (m) =>
checkboxCellFromStatus(m.processingStatus, {
checkedWhen: ["completed"],
rejectedWhen: ["rejected"],
}),
},
{
key: "matchStatus",
label: t("matchStatus"),
align: "center",
value: (m) => tr.matchStatus(m.matchStatus),
cell: (m) => checkboxCellFromStatus(m.matchStatus, { checkedWhen: ["completed"] }),
},
{
key: "trace",
label: "",
value: () => "",
cell: (m) =>
m.materialLotNo && onTrace ? (
<MuiLink
component="button"
type="button"
variant="body2"
underline="hover"
onClick={() =>
onTrace({
lotNo: m.materialLotNo,
itemCode: m.materialItemCode,
})
}
>
{t("traceMaterialLot")}
</MuiLink>
) : (
"—"
),
},
], [t, tr, stockUom, onTrace]);

const bomUpstreamCols = useMemo((): FilterableColumnDef<BomUpstream>[] => [
{
key: "jobOrder",
label: t("jobOrder"),
value: (u) => u.jobOrderCode || "—",
cell: (u) => (
<ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} />
),
},
{ key: "material", label: t("itemCode"), value: (u) => u.materialItemCode },
{ key: "materialLot", label: t("itemLot"), value: (u) => u.materialLotNo },
{
key: "materialQty",
label: t("materialQty"),
align: "right",
value: (u) => formatNum(Number(u.materialQty)),
},
{
key: "qtyPerUnit",
label: t("qtyPerUnit"),
align: "right",
value: (u) =>
u.bomQtyPerUnit != null ? formatNum(Number(u.bomQtyPerUnit)) : "—",
},
{
key: "uom",
label: t("uom"),
value: () => stockUom,
},
], [t, stockUom]);

const bomDownstreamCols = useMemo((): FilterableColumnDef<(typeof bomTrace.downstream)[number]>[] => [
{
key: "jobOrder",
label: t("jobOrder"),
value: (d) => d.jobOrderCode || "—",
cell: (d) => (
<ItemTracingDocLink kind="jo" code={d.jobOrderCode} id={d.jobOrderId} />
),
},
{ key: "finishedItem", label: t("itemCode"), value: (d) => d.finishedItemCode },
{ key: "finishedLot", label: t("itemLot"), value: (d) => d.finishedLotNo },
{
key: "fgQty",
label: t("fgQty"),
align: "right",
value: (d) => formatQty(d.fgQty, d.fgUom?.trim() || ""),
},
{
key: "materialQty",
label: t("materialQty"),
align: "right",
value: (d) => formatQty(d.materialQtyUsed, stockUom),
},
], [t, stockUom]);

const bomRecipeCols = useMemo((): FilterableColumnDef<(typeof bomTrace.bomRecipe)[number]>[] => [
{ key: "material", label: t("itemCode"), value: (r) => r.materialItemCode },
{
key: "materialName",
label: t("detailItemName"),
value: (r) => r.materialItemName,
},
{
key: "qtyPerUnit",
label: t("qtyPerUnit"),
align: "right",
value: (r) => formatNum(Number(r.qtyPerUnit)),
},
{ key: "uom", label: t("uom"), value: (r) => r.uom },
], [t]);

type JoPickRow = (typeof presentationRows.joPicks)[number];
const joPickCols = useMemo((): FilterableColumnDef<JoPickRow>[] => [
{
key: "pickOrder",
label: t("pickOrder"),
value: (row) => row.pickOrderCode || row.consoCode || "—",
cell: (row) =>
row.pickOrderCode ? (
<ItemTracingDocLink
kind="jodetail"
code={row.pickOrderCode}
id={row.pickOrderId}
consoCode={row.consoCode || row.pickOrderCode}
targetDate={normalizeTargetDateForLink(
row.targetDate || row.pickedAt,
)}
openInNewTab
/>
) : (
"—"
),
},
{ key: "material", label: t("Item"), value: (row) => row.materialItemCode },
{
key: "lotNo",
label: t("lotNo"),
value: (row) => row.materialLotNo || "—",
cell: (row) =>
row.materialLotNo ? (
<ItemTracingLotTraceLink
label={row.materialLotNo}
lotNo={row.materialLotNo}
itemCode={row.materialItemCode}
variant="body2"
/>
) : (
"—"
),
},
{
key: "pickedQty",
label: t("pickedQty"),
align: "right",
value: (row) => formatQty(row.qty, row.uom || stockUom),
},
{
key: "assignedStep",
label: t("detailAssignedStep"),
value: (row) => row.assignedStepName || "—",
},
{
key: "timestamp",
label: t("timestamp"),
value: (row) => row.pickedAt ?? "—",
},
], [t, stockUom]);

type JoPickLine = NonNullable<typeof joPrelude>["pickOrders"][number]["lines"][number];
const joPickLineCols = useMemo((): FilterableColumnDef<JoPickLine>[] => [
{
key: "material",
label: t("Item"),
value: (line) =>
line.itemName ? `${line.itemCode} · ${line.itemName}` : line.itemCode,
},
{
key: "plannedQty",
label: t("requiredQty"),
align: "right",
value: (line) => formatQty(Number(line.requiredQty), stockUom),
},
{
key: "pickedQty",
label: t("pickedQty"),
align: "right",
value: (line) => formatQty(Number(line.pickedQty), stockUom),
},
{ key: "status", label: t("status"), value: (line) => tr.pickStatus(line.status) },
], [t, tr, stockUom]);

return (
<Paper variant="outlined">
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
variant="scrollable"
scrollButtons="auto"
>
<Tab label={t("tabOrigins")} />
<Tab label={t("tabQc")} />
<Tab label={t("tabOutbound")} />
<Tab label={t("tabStockTake")} />
<Tab label={t("tabAdjustments")} />
<Tab label={t("tabTransfers")} />
<Tab label={t("tabBom")} />
{joPrelude && <Tab label={t("tabJoPick")} />}
</Tabs>
<Box sx={{ p: 2, overflowX: "auto" }}>
{multiLocation && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("sectionsMultiLocationHint")}
</Typography>
)}
{activeTab === "origins" && (
<FilterableDataTable
rows={mergedOrigins}
columns={originCols}
getRowKey={(o) => `${o.inventoryLotId}-${o.stockInLineId}`}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "qc" && (
<FilterableDataTable
rows={mergedQc}
columns={qcCols}
getRowKey={(q) => q.rowKey}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "outbound" && (
<FilterableDataTable
rows={mergedOutbound}
columns={outboundCols}
getRowKey={(u) => `${u.inventoryLotId}-${u.stockOutLineId}`}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "stockTake" && (
<FilterableDataTable
rows={mergedStockTake}
columns={stockTakeCols}
getRowKey={(e) => e.rowKey}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "adjustments" && (
<FilterableDataTable
rows={mergedAdjustments}
columns={adjustmentCols}
getRowKey={(a) => a.rowKey}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "transfers" && (
<FilterableDataTable
rows={mergedTransfers}
columns={transferCols}
getRowKey={(row) => row.rowKey}
emptyLabel={t("noRecords")}
/>
)}

{activeTab === "bom" && (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{t("bomDirection")}: <strong>{bomDirectionLabel}</strong>
</Typography>

<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
{t("bomUpstream")}
</Typography>
<Box sx={{ mb: 2 }}>
{useJoMaterialInputs ? (
<FilterableDataTable
rows={joPrelude!.materialInputs}
columns={bomUpstreamJoCols}
getRowKey={(_, i) => `up-jo-${i}`}
emptyLabel={t("noRecords")}
/>
) : (
<FilterableDataTable
rows={bomTrace.upstream}
columns={bomUpstreamCols}
getRowKey={(_, i) => `up-${i}`}
emptyLabel={t("noRecords")}
/>
)}
</Box>

<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("bomDownstream")}
</Typography>
<Box sx={{ mb: 2 }}>
<FilterableDataTable
rows={bomTrace.downstream}
columns={bomDownstreamCols}
getRowKey={(_, i) => `down-${i}`}
emptyLabel={t("noRecords")}
/>
</Box>

<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("bomRecipe")}
</Typography>
<FilterableDataTable
rows={bomTrace.bomRecipe}
columns={bomRecipeCols}
getRowKey={(_, i) => `recipe-${i}`}
emptyLabel={t("noRecords")}
/>
</Box>
)}

{activeTab === "joPick" && joPrelude && (
<Box>
{presentationRows.joPicks.length > 0 ? (
<FilterableDataTable
rows={presentationRows.joPicks}
columns={joPickCols}
getRowKey={(row) => row.id}
emptyLabel={t("noRecords")}
/>
) : joPrelude.pickOrders.length === 0 ? (
<Typography color="text.secondary" variant="body2">
{t("noRecords")}
</Typography>
) : (
joPrelude.pickOrders.map((po) => (
<Box key={po.pickOrderId} sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
<ItemTracingDocLink
kind="jodetail"
code={po.pickOrderCode}
id={po.pickOrderId}
consoCode={po.consoCode || po.pickOrderCode}
targetDate={normalizeTargetDateForLink(po.targetDate)}
openInNewTab
/>
{" · "}
<Chip
size="small"
label={tr.pickStatus(po.status)}
variant="outlined"
sx={{ ml: 0.5 }}
/>
</Typography>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 1 }}
>
{[
po.targetDate && `${t("targetDate")}: ${po.targetDate}`,
po.completeDate && `${t("completeDate")}: ${po.completeDate}`,
]
.filter(Boolean)
.join(" · ")}
</Typography>
<FilterableDataTable
rows={po.lines}
columns={joPickLineCols}
getRowKey={(line) => String(line.pickOrderLineId)}
emptyLabel={t("noRecords")}
/>
</Box>
))
)}
</Box>
)}
</Box>
</Paper>
);
};

export default ItemTracingSections;

+ 100
- 0
src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx Ver arquivo

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

import { Box, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { StockTakeLifecycleStage } from "./traceStockTakeUtils";
import { formatQty } from "./traceQtyUtils";

type Props = {
stages: StockTakeLifecycleStage[];
uom?: string;
};

/** Vertical timeline of stock-take progression (first count → approve). */
const ItemTracingStockTakeLifecycle: React.FC<Props> = ({ stages, uom = "" }) => {
const { t } = useTranslation("itemTracing");

if (!stages.length) return null;

return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{stages.map((stage, idx) => {
const isLast = idx === stages.length - 1;
return (
<Box key={stage.key} sx={{ display: "flex", gap: 1 }}>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 12,
flexShrink: 0,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: stage.isActive ? "success.main" : "grey.400",
flexShrink: 0,
mt: 0.35,
zIndex: 1,
}}
/>
{!isLast && (
<Box
sx={{
width: 2,
flex: 1,
minHeight: 22,
bgcolor:
stage.isActive && stages[idx + 1]?.isActive ? "success.main" : "grey.300",
my: -0.5,
}}
/>
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 1 }}>
<Typography
variant="caption"
fontWeight={600}
display="block"
noWrap
color={stage.isActive ? "text.primary" : "text.disabled"}
>
{t(stage.label)}
</Typography>
{stage.isActive ? (
<Typography variant="caption" color="text.secondary" display="block">
{[
stage.qty != null
? stage.key === "round-created"
? `${t("stockTakeStageBookQty")}: ${formatQty(stage.qty, uom)}`
: formatQty(stage.qty, uom)
: null,
stage.key === "accepted" && stage.badQty != null
? `${t("variance")}: ${formatQty(stage.badQty, uom)}`
: stage.badQty != null && stage.badQty > 0
? `${t("unqualifiedQty")}: ${formatQty(stage.badQty, uom)}`
: null,
stage.handler ?? null,
stage.timestamp ?? null,
]
.filter(Boolean)
.join(" · ") || "—"}
</Typography>
) : (
<Typography variant="caption" color="text.disabled" display="block">
</Typography>
)}
</Box>
</Box>
);
})}
</Box>
);
};

export default ItemTracingStockTakeLifecycle;

+ 291
- 0
src/components/ItemTracing/ItemTracingSummary.tsx Ver arquivo

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

import {
Box,
Button,
Chip,
Grid,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import ItemTracingDocLink from "./ItemTracingDocLink";
import { createTraceLabelTranslator } from "./traceLabelUtils";
import { formatQty } from "./traceQtyUtils";
import type { WarehouseFocusRequest } from "./ItemTracing";

type Props = {
data: ItemLotTraceResponse;
onFocusWarehouse?: (request: WarehouseFocusRequest) => void;
onExportExcel?: () => void;
};

type SummaryWarehouseRow = {
key: string;
inventoryLotId: number;
warehouseCode: string;
inQty: number;
outQty: number;
availableQty: number;
status: string;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */
const ItemTracingSummary: React.FC<Props> = ({
data,
onFocusWarehouse,
onExportExcel,
}) => {
const { t } = useTranslation("itemTracing");
const tr = useMemo(() => createTraceLabelTranslator(t), [t]);
const { lot, warehouseLines, joPrelude, alternateLocations, locationBlocks } =
data;

const combinedWarehouseRows = useMemo((): SummaryWarehouseRow[] => {
const primary = warehouseLines.map((w) => ({
key: `primary-${w.inventoryLotLineId}`,
inventoryLotId: lot.inventoryLotId,
warehouseCode: w.warehouseCode,
inQty: w.inQty,
outQty: w.outQty,
availableQty: w.availableQty,
status: w.status,
}));

const fromBlocks = (locationBlocks ?? []).flatMap((block) =>
block.warehouseLines.map((w) => ({
key: `alt-${block.inventoryLotId}-${w.inventoryLotLineId}`,
inventoryLotId: block.inventoryLotId,
warehouseCode: w.warehouseCode,
inQty: w.inQty,
outQty: w.outQty,
availableQty: w.availableQty,
status: w.status,
})),
);

if (fromBlocks.length > 0) {
return [...primary, ...fromBlocks];
}

// Fallback when location blocks are absent but alternateLocations exist.
const fromAlts = alternateLocations.map((loc) => ({
key: `alt-loc-${loc.inventoryLotId}-${loc.inventoryLotLineId}`,
inventoryLotId: loc.inventoryLotId,
warehouseCode: loc.warehouseCode,
inQty: 0,
outQty: 0,
availableQty: loc.availableQty,
status: "",
}));
return [...primary, ...fromAlts];
}, [
warehouseLines,
locationBlocks,
alternateLocations,
lot.inventoryLotId,
]);

const totalAvailable = combinedWarehouseRows.reduce(
(s, w) => s + (w.availableQty ?? 0),
0,
);

return (
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
>
<Typography variant="overline" color="text.secondary">
{t("summary")}
</Typography>
{onExportExcel && (
<Button
size="small"
variant="outlined"
onClick={onExportExcel}
title={t("exportExcelTooltip")}
>
{t("exportExcel")}
</Button>
)}
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ md: "center" }}
sx={{ mt: 1 }}
>
<Box sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight={700}>
{lot.lotNo || "—"}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{lot.itemCode} · {lot.itemName}
</Typography>
</Box>
<Chip
label={`${t("totalAvailable")}: ${formatQty(
totalAvailable,
lot.uom,
)}`}
color="primary"
variant="outlined"
/>
</Stack>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("expiryDate")}
</Typography>
<Typography variant="body2">{lot.expiryDate ?? "—"}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("productionDate")}
</Typography>
<Typography variant="body2">{lot.productionDate ?? "—"}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("stockInDate")}
</Typography>
<Typography variant="body2">{lot.stockInDate ?? "—"}</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("uom")}
</Typography>
<Typography variant="body2">{lot.uom || "—"}</Typography>
</Grid>
</Grid>
{joPrelude && (
<Box sx={{ mt: 2, p: 1.5, bgcolor: "action.hover", borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
{t("joContext")}
</Typography>
<Grid container spacing={1}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("jobOrder")}
</Typography>
<Typography variant="body2">
{joPrelude.jobOrder.jobOrderId != null ? (
<ItemTracingDocLink
kind="jo"
id={joPrelude.jobOrder.jobOrderId}
code={joPrelude.jobOrder.jobOrderCode}
/>
) : (
joPrelude.jobOrder.jobOrderCode || "—"
)}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("status")}
</Typography>
<Typography variant="body2">
{tr.joStatus(joPrelude.jobOrder.status)}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("planStart")}
</Typography>
<Typography variant="body2">
{joPrelude.jobOrder.planStart ?? "—"}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
{t("plannedQty")}
</Typography>
<Typography variant="body2">
{formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)}
</Typography>
</Grid>
</Grid>
</Box>
)}
{combinedWarehouseRows.length > 0 && (
<Box sx={{ mt: 3 }}>
<Table size="small" sx={{ tableLayout: "auto" }}>
<TableHead>
<TableRow>
<TableCell>{t("warehouse")}</TableCell>
<TableCell align="right">{t("inQty")}</TableCell>
<TableCell align="right">{t("outQty")}</TableCell>
<TableCell align="right">{t("available")}</TableCell>
<TableCell>{t("status")}</TableCell>
{onFocusWarehouse && (
<TableCell align="right">{t("action")}</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{combinedWarehouseRows.map((w) => (
<TableRow key={w.key}>
<TableCell>{w.warehouseCode || "—"}</TableCell>
<TableCell align="right">
{formatQty(w.inQty, lot.uom)}
</TableCell>
<TableCell align="right">
{formatQty(w.outQty, lot.uom)}
</TableCell>
<TableCell align="right">
{formatQty(w.availableQty, lot.uom)}
</TableCell>
<TableCell>
{w.status ? (
<Chip
size="small"
label={tr.lotLineStatus(w.status)}
variant="outlined"
/>
) : (
"—"
)}
</TableCell>
{onFocusWarehouse && (
<TableCell align="right">
{w.warehouseCode?.trim() ? (
<Button
size="small"
variant="outlined"
onClick={() =>
onFocusWarehouse({
inventoryLotId: w.inventoryLotId,
warehouseCode: w.warehouseCode,
})
}
>
{t("focusWarehouseInGraph")}
</Button>
) : (
"—"
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
</Paper>
);
};

export default ItemTracingSummary;

+ 58
- 0
src/components/ItemTracing/TraceFlowEdge.tsx Ver arquivo

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

import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
import { buildTraceFlowCorridorPath } from "./traceFlowEdgeLayout";

export type TraceFlowEdgeData = {
offset?: number;
pathMode?: "smooth" | "corridor";
corridorY?: number;
corridorEntryOffset?: number;
corridorExitOffset?: number;
corridorBranchOffset?: number;
};

export const TraceFlowEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps) => {
const edgeData = data as TraceFlowEdgeData | undefined;
const offset = edgeData?.offset ?? 0;

const path =
edgeData?.pathMode === "corridor" && edgeData.corridorY != null
? buildTraceFlowCorridorPath(
sourceX,
sourceY,
targetX,
targetY,
edgeData.corridorY,
edgeData.corridorEntryOffset,
edgeData.corridorExitOffset,
edgeData.corridorBranchOffset,
)
: getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8,
offset,
})[0];

return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />;
};

export const traceFlowEdgeTypes = {
traceFlow: TraceFlowEdge,
};

+ 878
- 0
src/components/ItemTracing/TraceFlowNodes.tsx Ver arquivo

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

import { memo, useEffect, useState } from "react";
import {
Handle,
Position,
useReactFlow,
useUpdateNodeInternals,
type Node,
type NodeProps,
} from "@xyflow/react";
import {
Box,
Chip,
Divider,
IconButton,
Paper,
Tooltip,
Typography,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import { useTranslation } from "react-i18next";
import ItemTracingDocLink from "./ItemTracingDocLink";
import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle";
import { TraceFlowNodeData } from "./buildReactFlowGraph";
import { NODE_HEIGHT, NODE_WIDTH, NODE_WIDTH_BRANCH, DO_GROUP_HEADER } from "./traceFlowConstants";
import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout";
import { kindColor, kindLabelKey } from "./traceFlowNodeUtils";
import { formatQty, formatSignedQty } from "./traceQtyUtils";
import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils";
import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils";

const PHASE_LABEL_INNER = 96;
/** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */
const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320;

const GroupCollapseButton = ({
collapsed,
onToggle,
collapseLabel,
expandLabel,
}: {
collapsed: boolean;
onToggle?: () => void;
collapseLabel: string;
expandLabel: string;
}) => (
<IconButton
size="small"
aria-label={collapsed ? expandLabel : collapseLabel}
aria-expanded={!collapsed}
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{
pointerEvents: "all",
p: 0.25,
ml: "auto",
flexShrink: 0,
color: "warning.dark",
}}
>
{collapsed ? (
<ExpandMoreIcon fontSize="small" />
) : (
<ExpandLessIcon fontSize="small" />
)}
</IconButton>
);

/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */
export const TraceFlowEventNode = memo(function TraceFlowEventNode({
id,
data,
}: NodeProps<Node<TraceFlowNodeData>>) {
const { t } = useTranslation("itemTracing");
const { setNodes } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const node = data.layoutNode;
const color = kindColor(node.kind);
const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC";
const isStockTake = node.kind === "STOCK_TAKE";
const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null;
const [lifecycleExpanded, setLifecycleExpanded] = useState(false);
const lifecycleStages = hasLifecycle
? buildStockTakeLifecycleStages(node.stockTakeRecordDetail)
: [];
const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH;

useEffect(() => {
if (!hasLifecycle) return;
const nextH = lifecycleExpanded
? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA
: NODE_HEIGHT;
setNodes((nds) =>
nds.map((n) => {
if (n.id !== id) return n;
return {
...n,
height: nextH,
style: { ...(n.style ?? {}), width: nodeWidth, height: nextH },
measured: {
width: n.measured?.width ?? n.width ?? nodeWidth,
height: nextH,
},
};
}),
);
const raf = requestAnimationFrame(() => updateNodeInternals(id));
return () => cancelAnimationFrame(raf);
}, [
hasLifecycle,
lifecycleExpanded,
id,
nodeWidth,
setNodes,
updateNodeInternals,
]);

const chipLabel =
node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim()
? node.putawayStatusLabel
: isQc && node.qcTypeLabel?.trim()
? node.qcTypeLabel
: t(kindLabelKey(node.kind));
const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp");
const isDoOut = node.kind === "DO_OUT";
const doOutKindChipSx = {
height: data.compact ? 18 : 20,
"& .MuiChip-label": {
px: 0.75,
fontSize: data.compact ? "0.65rem" : "0.7rem",
fontWeight: 600,
},
} as const;
const stopCardClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
const doOutboundKindChips =
isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? (
<>
{node.doOutboundIsExtra ? (
<Chip
size="small"
label={t("doOutboundExtra")}
color="secondary"
sx={doOutKindChipSx}
onClick={stopCardClick}
onMouseDown={stopCardClick}
/>
) : null}
{node.doOutboundIsReplenish ? (
<Chip
size="small"
label={t("doOutboundReplenish")}
color="success"
sx={doOutKindChipSx}
onClick={stopCardClick}
onMouseDown={stopCardClick}
/>
) : null}
</>
) : null;
const titleContent = isDoOut ? (
<Box
component="span"
sx={{
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: 0.5,
maxWidth: "100%",
}}
>
{node.docLinkKind && node.refCode ? (
<ItemTracingDocLink
kind={node.docLinkKind}
code={node.refCode}
id={node.refId}
consoCode={node.consoCode ?? node.refCode}
ticketNo={node.docLinkTicketNo}
targetDate={node.docLinkTargetDate}
openInNewTab
/>
) : (
node.refCode || "—"
)}
</Box>
) : node.docLinkKind && node.refCode ? (
<ItemTracingDocLink
kind={node.docLinkKind}
code={node.refCode}
id={node.refId}
consoCode={node.consoCode ?? node.refCode}
ticketNo={node.docLinkTicketNo}
targetDate={node.docLinkTargetDate}
openInNewTab
/>
) : (
node.title
);
const incomingCount = data.incomingHandleCount ?? 0;
const outgoingCount = data.outgoingHandleCount ?? 0;
const searchActive = data.searchActive ?? false;
const searchMatch = data.searchMatch ?? false;
const searchFocused = data.searchFocused ?? false;
const nodeSelected = data.nodeSelected ?? false;

const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };

const activeCount = lifecycleStages.filter((s) => s.isActive).length;

const card = (
<Paper
variant="outlined"
sx={{
width: nodeWidth,
minWidth: nodeWidth,
maxWidth: nodeWidth,
height: NODE_HEIGHT,
display: "flex",
flexDirection: "column",
overflow: "hidden",
position: "relative",
borderColor: searchFocused
? "primary.main"
: nodeSelected
? "primary.main"
: searchMatch
? "warning.main"
: isQc
? `${color}.main`
: undefined,
borderWidth: searchFocused || searchMatch || nodeSelected ? 2 : isQc ? 2 : 1,
opacity: searchActive && !searchMatch ? 0.35 : 1,
transition: "opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease",
boxShadow: searchFocused ? 8 : nodeSelected ? 6 : searchMatch ? 4 : undefined,
cursor: "pointer",
"&:hover": { boxShadow: searchFocused || searchMatch || nodeSelected ? undefined : 6 },
}}
>
{incomingCount > 0 &&
Array.from({ length: incomingCount }, (_, i) => (
<Handle
key={`in-${i}`}
id={`in-${i}`}
type="target"
position={Position.Left}
style={{
...hiddenHandleStyle,
top: traceFlowHandleTopPercent(i, incomingCount),
left: 0,
}}
/>
))}
<Box sx={{ height: 4, bgcolor: `${color}.main`, flexShrink: 0 }} />
<Box
sx={{
p: data.compact ? 1 : 1.25,
pb: hasLifecycle ? (data.compact ? 0.5 : 0.75) : undefined,
flex: 1,
minHeight: 0,
overflow: "hidden",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 0.5,
mb: 0.75,
}}
>
{isQc && (
<Box
sx={{
width: 8,
height: 8,
bgcolor: `${color}.main`,
transform: "rotate(45deg)",
flexShrink: 0,
}}
/>
)}
<Chip size="small" label={chipLabel} color={color} />
{doOutboundKindChips}
</Box>
<Box
sx={{
lineHeight: 1.3,
fontSize: data.compact ? "0.8rem" : undefined,
fontWeight: 700,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
...(isDoOut
? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5 }
: {
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}),
}}
title={typeof node.refCode === "string" && node.refCode.trim() ? node.refCode : node.title}
>
{titleContent}
</Box>
{node.subtitle?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.subtitle}
sx={{
mt: 0.5,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{node.subtitle}
</Typography>
) : null}
{node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? (
<Typography
variant="body2"
fontWeight={600}
title={node.meta?.trim() || node.traceItemCode}
sx={{
mt: 0.5,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{t("Item")}: {node.meta?.trim() || node.traceItemCode}
</Typography>
) : null}
{(node.qty != null || node.kind === "STOCK_TAKE") && (
<Typography
variant="body2"
fontWeight={600}
title={
node.kind === "ADJUSTMENT"
? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
: formatQty(node.qty, node.uom)
}
sx={{
mt: 0.5,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
color:
node.kind === "DO_OUT" ||
node.kind === "JO_OUT" ||
node.kind === "MATERIAL_PICK"
? resolveDoOutboundQtyColor(
node.qty,
node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false,
)
: undefined,
}}
>
{node.kind === "PURCHASE"
? t("detailOrderQty")
: node.kind === "STOCK_TAKE"
? t("after")
: node.kind === "ADJUSTMENT"
? t("variance")
: node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL"
? t("unqualifiedQty")
: node.kind === "PRODUCTION_STEP"
? t("processOutputQty")
: t("qty")}
:{" "}
{node.kind === "ADJUSTMENT"
? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
: formatQty(node.qty, node.uom)}
</Typography>
)}
{node.kind === "STOCK_TAKE" ? (
<Typography
variant="body2"
fontWeight={600}
title={formatQty(node.stockTakeVarianceQty, node.uom)}
sx={{
mt: 0.25,
fontSize: data.compact ? "0.8rem" : undefined,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
color:
node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0
? "success.main"
: node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0
? "error.main"
: undefined,
}}
>
{t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)}
</Typography>
) : null}
{node.traceLotNo ? (
<ItemTracingLotTraceLink
label={
node.kind === "BYPRODUCT"
? t("traceByproductLot")
: node.kind === "REPACK"
? t("traceRepackLot")
: t("traceMaterialLot")
}
lotNo={node.traceLotNo}
itemCode={node.traceItemCode}
stopPropagation
sx={{ mt: 0.5, display: "inline-block" }}
/>
) : null}
{node.warehouseCode?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.warehouseCode}
sx={{
mt: 0.5,
fontWeight: 600,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{t("warehouse")}: {node.warehouseCode}
</Typography>
) : null}
{(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
node.processingStatusLabel?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.processingStatusLabel}
sx={{
mt: 0.5,
fontWeight: 600,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
}}
>
{t("processingStatus")}:{" "}
<Box
component="span"
sx={{
color: (() => {
const c = pickStatusValueColor(node.processingStatus);
return c === "default" ? "text.primary" : `${c}.main`;
})(),
fontWeight: 700,
}}
>
{node.processingStatusLabel}
</Box>
</Typography>
) : null}
{(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
node.matchStatusLabel?.trim() ? (
<Typography
variant="caption"
color="text.secondary"
display="block"
title={node.matchStatusLabel}
sx={{
mt: 0.5,
fontWeight: 600,
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
display: "-webkit-box",
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
}}
>
{t("matchStatus")}:{" "}
<Box
component="span"
sx={{
color: (() => {
const c = pickStatusValueColor(node.matchStatus);
return c === "default" ? "text.primary" : `${c}.main`;
})(),
fontWeight: 700,
}}
>
{node.matchStatusLabel}
</Box>
</Typography>
) : null}
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>
{dateLabel}
</Typography>
</Box>
{hasLifecycle ? (
<Box
sx={{
flexShrink: 0,
borderTop: 1,
borderColor: "divider",
px: data.compact ? 0.75 : 1,
py: 0.25,
}}
>
<Box
role="button"
tabIndex={0}
aria-expanded={lifecycleExpanded}
aria-label={lifecycleExpanded ? t("stockTakeStageCollapse") : t("stockTakeStageExpand")}
onClick={(e) => {
e.stopPropagation();
setLifecycleExpanded((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setLifecycleExpanded((v) => !v);
}
}}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 0.5,
width: "100%",
py: 0.25,
px: 0.5,
borderRadius: 1,
cursor: "pointer",
userSelect: "none",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Typography variant="caption" fontWeight={700} color="secondary.main" noWrap>
{t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
</Typography>
{lifecycleExpanded ? (
<ExpandLessIcon fontSize="small" color="secondary" />
) : (
<ExpandMoreIcon fontSize="small" color="secondary" />
)}
</Box>
</Box>
) : null}
{outgoingCount > 0 &&
Array.from({ length: outgoingCount }, (_, i) => (
<Handle
key={`out-${i}`}
id={`out-${i}`}
type="source"
position={Position.Right}
style={{
...hiddenHandleStyle,
top: traceFlowHandleTopPercent(i, outgoingCount),
right: 0,
}}
/>
))}
</Paper>
);

// Inline under the card so pan/zoom keeps the panel attached (Popper portals to body).
const lifecyclePanel =
hasLifecycle && lifecycleExpanded ? (
<Paper
variant="outlined"
className="nowheel nodrag nopan"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
sx={{
width: nodeWidth,
maxWidth: nodeWidth,
mt: 0.75,
p: 1.25,
boxShadow: 4,
borderColor: "secondary.main",
borderWidth: 1.5,
maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24,
overflow: "auto",
overscrollBehavior: "contain",
bgcolor: "background.paper",
pointerEvents: "all",
}}
>
<Typography variant="caption" fontWeight={700} color="secondary.main" sx={{ mb: 1, display: "block" }}>
{t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
</Typography>
<Divider sx={{ mb: 1 }} />
<ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} />
</Paper>
) : null;

const wrapped = (
<Box
sx={{
width: nodeWidth,
height: lifecycleExpanded
? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA
: NODE_HEIGHT,
overflow: "visible",
}}
>
{card}
{lifecyclePanel}
</Box>
);

if (node.meta) {
return (
<Tooltip title={node.meta} arrow placement="top">
{wrapped}
</Tooltip>
);
}
return wrapped;
});

export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({
data,
}: NodeProps<Node<TraceFlowNodeData>>) {
return (
<Box
sx={{
width: PHASE_LABEL_INNER,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 0.75,
py: 1,
borderLeft: `4px solid ${data.phaseColor ?? "#757575"}`,
backgroundImage: `linear-gradient(${data.phaseColor ?? "#757575"}14, ${data.phaseColor ?? "#757575"}14)`,
borderRadius: 1,
}}
>
<Typography
variant="caption"
fontWeight={700}
sx={{
writingMode: "vertical-rl",
textOrientation: "mixed",
color: data.phaseColor ?? "#757575",
letterSpacing: 0.5,
}}
>
{data.phaseLabel}
</Typography>
</Box>
);
});

export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({
data,
}: NodeProps<Node<TraceFlowNodeData>>) {
return (
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ width: 80, textAlign: "center" }}>
{data.dateLabel}
</Typography>
);
});

export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({
data,
}: NodeProps<Node<TraceFlowNodeData>>) {
const { t } = useTranslation("itemTracing");
const node = data.layoutNode;
const incomingCount = data.incomingHandleCount ?? 0;
const searchActive = data.searchActive ?? false;
const searchMatch = data.searchMatch ?? false;
const searchFocused = data.searchFocused ?? false;
const collapsed = data.groupCollapsed === true;
const width = node.groupBoxWidth ?? NODE_WIDTH;
const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };

return (
<Box
sx={{
width,
height,
border: 2,
borderStyle: "dashed",
borderColor: searchFocused
? "primary.main"
: searchMatch
? "warning.main"
: "warning.light",
borderRadius: 1,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45),
opacity: searchActive && !searchMatch ? 0.35 : 1,
boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
position: "relative",
overflow: "hidden",
pointerEvents: "none",
}}
>
{incomingCount > 0 &&
Array.from({ length: incomingCount }, (_, i) => (
<Handle
key={`in-${i}`}
id={`in-${i}`}
type="target"
position={Position.Left}
style={{
...hiddenHandleStyle,
top: traceFlowHandleTopPercent(i, incomingCount),
left: 0,
pointerEvents: "all",
}}
/>
))}
<Box
sx={{
height: DO_GROUP_HEADER,
px: 1.25,
display: "flex",
alignItems: "center",
gap: 1,
overflow: "hidden",
bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35),
borderBottom: collapsed ? 0 : 1,
borderColor: "warning.light",
}}
>
<Typography variant="caption" fontWeight={700} noWrap sx={{ color: "warning.dark", minWidth: 0 }}>
{node.title}
</Typography>
{node.groupTotalQty != null ? (
<Typography variant="caption" fontWeight={600} noWrap sx={{ color: "warning.dark", flexShrink: 0 }}>
{t("flowDoGroupTotalQty", {
qtyLabel: formatQty(node.groupTotalQty, node.groupUom),
})}
</Typography>
) : null}
<GroupCollapseButton
collapsed={collapsed}
onToggle={data.onToggleGroupCollapse}
collapseLabel={t("flowGroupCollapse")}
expandLabel={t("flowGroupExpand")}
/>
</Box>
</Box>
);
});

export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({
data,
}: NodeProps<Node<TraceFlowNodeData>>) {
const { t } = useTranslation("itemTracing");
const node = data.layoutNode;
const incomingCount = data.incomingHandleCount ?? 0;
const outgoingCount = data.outgoingHandleCount ?? 0;
const searchActive = data.searchActive ?? false;
const searchMatch = data.searchMatch ?? false;
const searchFocused = data.searchFocused ?? false;
const collapsed = data.groupCollapsed === true;
const width = node.groupBoxWidth ?? NODE_WIDTH;
const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };

return (
<Box
sx={{
width,
height,
border: 2,
borderStyle: "dashed",
borderColor: searchFocused
? "primary.main"
: searchMatch
? "warning.main"
: "warning.light",
borderRadius: 1,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45),
opacity: searchActive && !searchMatch ? 0.35 : 1,
boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
position: "relative",
overflow: "hidden",
pointerEvents: "none",
}}
>
{incomingCount > 0 &&
Array.from({ length: incomingCount }, (_, i) => (
<Handle
key={`in-${i}`}
id={`in-${i}`}
type="target"
position={Position.Left}
style={{
...hiddenHandleStyle,
top: traceFlowHandleTopPercent(i, incomingCount),
left: 0,
pointerEvents: "all",
}}
/>
))}
<Box
sx={{
height: DO_GROUP_HEADER,
px: 1.25,
display: "flex",
alignItems: "center",
gap: 0.5,
bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35),
borderBottom: collapsed ? 0 : 1,
borderColor: "warning.light",
}}
>
<Typography variant="caption" fontWeight={700} noWrap sx={{ flex: 1, color: "warning.dark", minWidth: 0 }}>
{node.title}
</Typography>
<GroupCollapseButton
collapsed={collapsed}
onToggle={data.onToggleGroupCollapse}
collapseLabel={t("flowGroupCollapse")}
expandLabel={t("flowGroupExpand")}
/>
</Box>
{outgoingCount > 0 &&
Array.from({ length: outgoingCount }, (_, i) => (
<Handle
key={`out-${i}`}
id={`out-${i}`}
type="source"
position={Position.Right}
style={{
...hiddenHandleStyle,
top: traceFlowHandleTopPercent(i, outgoingCount),
right: 0,
pointerEvents: "all",
}}
/>
))}
</Box>
);
});

export const traceFlowNodeTypes = {
traceEvent: TraceFlowEventNode,
doGroup: TraceFlowDoGroupNode,
pickGroup: TraceFlowPickGroupNode,
phaseLabel: TraceFlowPhaseLabelNode,
dateHeader: TraceFlowDateHeaderNode,
};

+ 278
- 0
src/components/ItemTracing/buildExtendedTraceGraphNodes.ts Ver arquivo

@@ -0,0 +1,278 @@
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import dayjs from "dayjs";
import {
TraceGraphDetailLabels,
TraceGraphNode,
TraceGraphBuildScope,
scopePrefix,
withNodeScope,
} from "./buildTraceGraphNodes";
import { formatQty } from "./traceQtyUtils";
import {
formatDoOutboundKindLabel,
resolveDoOutboundChipFlags,
} from "./traceLabelUtils";
import { resolveDoOutboundDocLink } from "./traceDocLinkUtils";
import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory";

export interface ExtendedTraceGraphLabels extends TraceGraphDetailLabels {
nodeOpen: string;
nodeFail: string;
nodeDoOut: string;
nodeReplenishmentCreated: string;
nodeReturn: string;
nodeRepack: string;
traceRepackLot: string;
doOutboundExtra: string;
doOutboundReplenish: string;
detailDoOutboundKind: string;
}

const shopDisplayLabel = (shopCode?: string, shopName?: string): string => {
const code = shopCode?.trim();
const name = shopName?.trim();
if (code && name) return `${code} · ${name}`;
return name || code || "";
};

export const buildExtendedTraceGraphNodes = (
data: ItemLotTraceResponse,
labels: ExtendedTraceGraphLabels,
scope?: TraceGraphBuildScope,
): TraceGraphNode[] => {
const nodes: TraceGraphNode[] = [];
let seq = 0;
const stockUom = data.lot.uom;
const idPfx = scopePrefix(scope);
const lb = scope?.locationBlockKeys ?? false;

(data.openMovements ?? []).forEach((o, i) => {
nodes.push({
id: lb ? `${idPfx}open-${i}-${o.stockInLineId}` : `${idPfx}open-${o.stockInLineId}`,
kind: "OPEN",
timestamp: o.timestamp,
sortKey: parseSortKey(o.timestamp, seq++),
title: labels.nodeOpen,
subtitle: [o.refCode, o.warehouseCode].filter(Boolean).join(" · ") || "—",
qty: o.qty,
uom: stockUom,
refCode: o.refCode,
categoryLabel: labels.categoryOpen,
details: detailsOf(
field(labels.detailType, labels.nodeOpen),
field(labels.detailInboundSiNo, o.refCode),
field(labels.detailWarehouse, o.warehouseCode),
field(labels.detailQty, formatQty(o.qty, stockUom)),
field(labels.detailHandler, o.handledBy),
field(labels.detailTime, o.timestamp),
fieldIf(labels.detailRemarks, o.remarks),
),
});
});

(data.failEvents ?? []).forEach((f, i) => {
nodes.push({
id: lb ? `${idPfx}fail-${i}-${f.failId}` : `${idPfx}fail-${f.failId}`,
kind: "FAIL",
timestamp: f.recordDate,
sortKey: parseSortKey(f.recordDate, seq++),
title: labels.nodeFail,
subtitle: [labels.tr.failType(f.failType), f.category].filter(Boolean).join(" · ") || "—",
qty: f.qty,
uom: stockUom,
refType: "JO_PICK",
refCode: f.pickOrderCode,
refId: f.pickOrderId,
docLinkKind: f.pickOrderCode ? "jodetail" : undefined,
consoCode: f.pickOrderCode,
docLinkTargetDate: f.recordDate?.trim()
? dayjs(f.recordDate).isValid()
? dayjs(f.recordDate).format("YYYY-MM-DD")
: f.recordDate.trim().slice(0, 10)
: undefined,
categoryLabel: labels.categoryQc,
details: detailsOf(
field(labels.detailType, labels.nodeFail),
field(labels.detailStatus, labels.tr.failType(f.failType)),
field(labels.pickOrder, f.pickOrderCode, {
linkKind: f.pickOrderCode ? "jodetail" : undefined,
linkCode: f.pickOrderCode,
linkId: f.pickOrderId,
consoCode: f.pickOrderCode,
linkTargetDate: f.recordDate?.trim()
? dayjs(f.recordDate).isValid()
? dayjs(f.recordDate).format("YYYY-MM-DD")
: f.recordDate.trim().slice(0, 10)
: undefined,
}),
field(labels.detailQty, formatQty(f.qty, stockUom)),
field(labels.detailHandler, f.handlerName),
field(labels.detailTime, f.recordDate),
fieldIf(labels.detailFailCategory, f.category),
),
});
});

(data.replenishmentEvents ?? []).forEach((r) => {
const supplyTo = shopDisplayLabel(r.shopCode, r.shopName);
nodes.push({
id: `${idPfx}replenish-${r.replenishmentId}`,
kind: "REPLENISHMENT_CREATED",
timestamp: r.timestamp,
sortKey: parseSortKey(r.timestamp, seq++),
title: labels.nodeReplenishmentCreated,
subtitle: r.sourceDoCode || r.replenishmentCode || "—",
qty: r.replenishQty,
uom: stockUom,
refType: "DO",
refCode: r.sourceDoCode,
refId: r.sourceDoId,
replenishmentStockOutLineId: r.stockOutLineId ?? undefined,
categoryLabel: labels.categoryOutbound,
details: detailsOf(
field(labels.detailType, labels.nodeReplenishmentCreated),
field(labels.deliveryOrder, r.sourceDoCode),
field(labels.detailSupplyTo, supplyTo || "—"),
field(labels.detailItemCode, r.itemNo),
field(labels.detailItemName, r.itemName),
field(labels.detailReplenishmentCode, r.replenishmentCode),
field(labels.detailQty, formatQty(r.replenishQty, stockUom)),
field(labels.detailReason, r.reason),
field(labels.detailHandler, r.handler),
field(labels.detailTime, r.timestamp),
),
});
});

(data.doDeliveries ?? []).forEach((d, i) => {
const supplyTo = shopDisplayLabel(d.shopCode, d.shopName);
const kindLabels = {
extra: labels.doOutboundExtra,
replenish: labels.doOutboundReplenish,
};
const chipFlags = resolveDoOutboundChipFlags({
isExtra: d.isExtra,
isReplenish: d.isReplenish,
ticketNo: d.ticketNo,
consoCode: d.consoCode,
releaseType: d.releaseType,
deliveryOrderPickOrderId: d.deliveryOrderPickOrderId,
relationshipId: d.relationshipId,
});
const outboundKindLabel = formatDoOutboundKindLabel(
chipFlags.isExtra,
chipFlags.isReplenish,
kindLabels,
);
const doCode = d.deliveryOrderCode || "—";
const docLink = resolveDoOutboundDocLink({
pickOrderCode: d.pickOrderCode,
pickOrderId: d.pickOrderId,
deliveryOrderCode: d.deliveryOrderCode,
ticketNo: d.ticketNo,
consoCode: d.consoCode,
timestamp: d.timestamp,
deliveryOrderPickOrderId: d.deliveryOrderPickOrderId,
});
nodes.push({
id: lb ? `${idPfx}dodel-${i}-${d.stockOutLineId}` : `${idPfx}do-${d.stockOutLineId}`,
kind: "DO_OUT",
timestamp: d.timestamp,
sortKey: parseSortKey(d.timestamp, seq++),
title: `${labels.nodeDoOut} · ${doCode}`,
subtitle: "",
qty: d.qty,
uom: stockUom,
refType: "DO",
refCode: docLink.displayCode,
refId: d.deliveryOrderId ?? d.pickOrderId,
docLinkKind: docLink.kind,
docLinkTicketNo: docLink.ticketNo,
docLinkTargetDate: docLink.targetDate,
consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode,
categoryLabel: labels.categoryOutbound,
doOutboundIsExtra: chipFlags.isExtra,
doOutboundIsReplenish: chipFlags.isReplenish,
doOutboundQtyChanged: d.qtyChanged === true,
outboundKindLabel,
warehouseCode: scope?.defaultWarehouseCode,
details: [
field(labels.detailType, labels.nodeDoOut),
...(outboundKindLabel
? [field(labels.detailDoOutboundKind, outboundKindLabel)]
: []),
field(labels.deliveryOrder, d.deliveryOrderCode),
...(d.deliveryNoteCode?.trim()
? [field(labels.deliveryNoteCode, d.deliveryNoteCode.trim())]
: []),
...(d.ticketNo?.trim()
? [field(labels.ticketNo, d.ticketNo.trim())]
: []),
field(labels.detailSupplyTo, supplyTo || "—"),
field(labels.pickOrder, d.pickOrderCode, {
linkKind: docLink.kind,
linkCode: d.pickOrderCode,
linkId: d.pickOrderId,
consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode,
linkTicketNo: docLink.ticketNo,
linkTargetDate: docLink.targetDate,
}),
field(labels.detailQty, formatQty(d.qty, stockUom)),
field(labels.detailHandler, d.handler),
field(labels.detailTime, d.timestamp),
],
});
});

(data.returnEvents ?? []).forEach((r, i) => {
nodes.push({
id: lb
? `${idPfx}return-${i}-${r.stockOutLineId}`
: `${idPfx}return-${r.stockOutLineId}-${r.timestamp}`,
kind: "RETURN",
timestamp: r.timestamp,
sortKey: parseSortKey(r.timestamp, seq++),
title: labels.nodeReturn,
subtitle: [labels.tr.movementType(r.movementType), r.refCode].filter(Boolean).join(" · ") || "—",
qty: r.qty,
uom: stockUom,
refCode: r.refCode,
categoryLabel: labels.categoryOutbound,
details: detailsOf(
field(labels.detailType, labels.tr.movementType(r.movementType)),
field(labels.detailReturnRef, r.refCode),
field(labels.detailWarehouse, r.warehouseCode),
field(labels.detailQty, formatQty(r.qty, stockUom)),
field(labels.detailHandler, r.handler),
field(labels.detailTime, r.timestamp),
fieldIf(labels.detailRemarks, r.remarks),
),
});
});

(data.lotRelations ?? []).forEach((rel, i) => {
nodes.push({
id: `repack-${rel.inventoryLotId ?? i}-${rel.lotNo}`,
kind: "REPACK",
timestamp: rel.timestamp,
sortKey: parseSortKey(rel.timestamp, seq++),
title: `${rel.itemCode} · ${labels.nodeRepack}`,
subtitle: [rel.lotNo, rel.productLotNo].filter(Boolean).join(" · ") || rel.itemName,
qty: rel.qty,
uom: stockUom,
traceLotNo: rel.lotNo,
traceItemCode: rel.itemCode,
categoryLabel: labels.categoryTransfer,
details: [
field(labels.detailType, labels.nodeRepack),
field(labels.detailMaterial, `${rel.itemCode} · ${rel.itemName}`),
field(labels.detailLot, rel.lotNo),
field(labels.detailQty, formatQty(rel.qty, stockUom)),
field(labels.detailProductLotNo, rel.productLotNo),
field(labels.detailTime, rel.timestamp),
],
});
});

return nodes.map((n) => withNodeScope(n, scope));
};

+ 632
- 0
src/components/ItemTracing/buildJoPreludeGraphNodes.ts Ver arquivo

@@ -0,0 +1,632 @@
import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import {
TraceGraphDetailField,
TraceGraphDetailLabels,
TraceGraphNode,
} from "./buildTraceGraphNodes";
import {
buildProductionGraphNodes,
ProductionGraphLabels,
} from "./buildProductionGraphNodes";
import {
groupQcResultsBySession,
} from "./traceLabelUtils";
import { formatQty, formatSignedQty } from "./traceQtyUtils";
import {
createMaterialPickNode,
docLinkFromOriginType,
docLinkFromRefType,
field,
fieldIf,
detailsOf,
isJoProducedMaterial,
parseSortKey,
} from "./traceNodeFactory";
import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils";
import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils";

export interface JoPreludeGraphLabels extends TraceGraphDetailLabels {
nodeMaterialIn: string;
nodeMaterialPick: string;
nodePurchase: string;
nodeReceipt: string;
nodeJoCreated: string;
pickOrder: string;
}

type LotPickRef = {
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
};

type PreludeBuildContext = {
seenPurchaseLines: Set<number>;
seenInboundLots: Set<number>;
seenReceiptLots: Set<number>;
seenQcKeys: Set<string>;
seenPutawayKeys: Set<string>;
pickOrderTargetDateMap: Map<string, string>;
seq: number;
};

const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`;

const mergeLotPickMaps = (
target: Map<string, LotPickRef[]>,
source: Map<string, LotPickRef[]>,
): void => {
source.forEach((picks, key) => {
const list = target.get(key) ?? [];
picks.forEach((p) => {
if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) {
list.push(p);
}
});
if (list.length) target.set(key, list);
});
};

const buildLotPickOrderMap = (
materialInputs: ItemLotTraceJoPrelude["materialInputs"],
): Map<string, LotPickRef[]> => {
const map = new Map<string, LotPickRef[]>();
materialInputs.forEach((m) => {
const code = m.pickOrderCode?.trim();
if (!code) return;
const key = materialLotKey(m.materialItemCode, m.materialLotNo);
const list = map.get(key) ?? [];
if (!list.some((p) => p.pickOrderCode === code)) {
list.push({
pickOrderCode: code,
pickOrderId: m.pickOrderId,
consoCode: m.consoCode,
});
}
map.set(key, list);
const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
if (nestedInputs.length) {
mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs));
}
});
return map;
};

const buildLotPickOrderMapRecursive = (
materialInputs: ItemLotTraceMaterialInput[],
): Map<string, LotPickRef[]> => buildLotPickOrderMap(materialInputs);

const pickOrdersForLot = (
map: Map<string, LotPickRef[]>,
itemCode: string,
lotNo: string,
): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? [];

const pickOrderCodesLabel = (picks: LotPickRef[]) =>
picks.map((p) => p.pickOrderCode).join(" · ");

const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => {
if (!picks.length) return base;
const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`;
return base ? `${base} · ${suffix}` : suffix;
};

const pickOrderDetailFields = (
picks: LotPickRef[],
pickLabel: string,
): TraceGraphDetailField[] =>
picks.map((p) =>
field(pickLabel, p.pickOrderCode, {
linkKind: "pick",
linkCode: p.pickOrderCode,
linkId: p.pickOrderId,
consoCode: p.consoCode || p.pickOrderCode,
}),
);

const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++;

const pickCtx = (ctx: PreludeBuildContext) => ({
nextSeq: () => nextSeq(ctx),
pickOrderTargetDate: (pickOrderCode: string) =>
ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""),
});

const buildMaterialProductionNodes = (
m: ItemLotTraceMaterialInput,
labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
ctx: PreludeBuildContext,
): TraceGraphNode[] => {
const steps = m.productionSteps ?? [];
if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return [];

const matUom = m.materialUom?.trim() || "";
return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({
...node,
id: `mat-prod-${m.materialLotNo}-${node.id}`,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "),
}));
};

const buildMaterialStockInPreludeNodes = (
m: ItemLotTraceMaterialInput,
labels: JoPreludeGraphLabels,
lotPicks: Map<string, LotPickRef[]>,
ctx: PreludeBuildContext,
): TraceGraphNode[] => {
const nodes: TraceGraphNode[] = [];
const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);
const lotId = m.materialInventoryLotId;
const origin = m.stockInOrigin;
const matUom = m.materialUom?.trim() || "";

(m.purchaseEvents ?? []).forEach((p) => {
if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return;
ctx.seenPurchaseLines.add(p.purchaseOrderLineId);
const purchaseUnit = p.purchaseUnit;
const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" ");
const subtitle =
[supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode;
const meta = [supplierLabel].filter(Boolean).join(" · ");
nodes.push({
id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`,
kind: "PURCHASE",
timestamp: p.orderDate,
sortKey: parseSortKey(p.orderDate, nextSeq(ctx)),
title: labels.nodePurchase,
subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder),
qty: p.orderQty,
uom: purchaseUnit,
meta: meta || undefined,
refType: "PO",
refCode: p.purchaseOrderCode,
refId: p.purchaseOrderId,
docLinkKind: "po",
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryPurchase,
details: [
field(labels.detailItemCode, p.itemCode),
field(labels.detailItemName, p.itemName),
field(labels.detailSupplier, supplierLabel),
field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)),
field(labels.detailPurchaseUnit, p.purchaseUnit),
field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, {
linkKind: "po",
linkCode: p.purchaseOrderCode,
linkId: p.purchaseOrderId,
}),
field(labels.detailTime, p.orderDate),
field(labels.detailLot, m.materialLotNo),
...pickOrderDetailFields(picks, labels.pickOrder),
],
});
});

if (origin && lotId != null) {
const originType = origin.type.trim().toUpperCase();
const linkKind = docLinkFromOriginType(origin.type);
const subtitle = [
m.materialLotNo,
[origin.supplierCode, origin.supplierName].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(" · ");

if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) {
ctx.seenReceiptLots.add(lotId);
nodes.push({
id: `mat-receipt-${lotId}-${origin.stockInLineId}`,
kind: "RECEIPT",
timestamp: origin.receiptDate,
sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
title: labels.nodeReceipt,
subtitle: appendPickToSubtitle(
[origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode,
picks,
labels.pickOrder,
),
qty: origin.acceptedQty,
uom: matUom,
refType: origin.type,
refCode: origin.refCode,
refId: origin.refId,
docLinkKind: linkKind,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryReceipt,
details: [
field(labels.detailPurchaseOrderNo, origin.refCode, {
linkKind,
linkCode: origin.refCode,
linkId: origin.refId,
}),
field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
field(labels.detailTime, origin.receiptDate),
...pickOrderDetailFields(picks, labels.pickOrder),
],
});
} else if (
originType !== "PO" &&
originType !== "ADJ" &&
!ctx.seenInboundLots.has(lotId)
) {
// Nested JO_CREATED already represents this JO output lot (with 提料 links).
const skipJoOriginBecauseNested =
originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim());
if (!skipJoOriginBecauseNested) {
// ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整).
ctx.seenInboundLots.add(lotId);
const isJoOrigin = originType === "JO";
nodes.push({
id: `mat-in-${lotId}-${origin.stockInLineId}`,
kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN",
timestamp: origin.receiptDate,
sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)),
title: isJoOrigin
? labels.nodeJoCreated
: `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`,
subtitle: appendPickToSubtitle(
subtitle || m.materialItemCode,
picks,
labels.pickOrder,
),
qty: origin.acceptedQty,
uom: matUom,
refType: origin.type,
refCode: origin.refCode,
refId: origin.refId,
docLinkKind: linkKind,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase,
details: [
field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)),
field(
isJoOrigin
? labels.jobOrder
: originType === "PO"
? labels.detailPurchaseOrderNo
: labels.detailSourceDoc,
origin.refCode,
{
linkKind,
linkCode: origin.refCode,
linkId: origin.refId,
}),
field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")),
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
field(labels.detailQty, formatQty(origin.acceptedQty, matUom)),
field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
field(labels.detailTime, origin.receiptDate),
...pickOrderDetailFields(picks, labels.pickOrder),
],
});
}
}
}

groupQcResultsBySession(m.qcResults).forEach((group) => {
const first = group[0];
if (!first) return;
const key = `${first.stockInLineId}-${first.created}`;
if (ctx.seenQcKeys.has(key)) return;
ctx.seenQcKeys.add(key);
const allPassed = group.every((q) => q.qcPassed);
const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo]
.filter(Boolean)
.join(" · ");
const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
nodes.push({
id: `mat-qc-${key}`,
kind: "MATERIAL_QC",
timestamp: first.created,
sortKey: parseSortKey(first.created, nextSeq(ctx)),
title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
subtitle: "",
qty: totalFail,
uom: matUom,
meta: meta || undefined,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryQc,
qcTypeLabel: labels.tr.qcType(qcTypeRaw),
details: detailsOf(
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
field(labels.detailStatus, labels.tr.qcPassed(allPassed)),
field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)),
field(labels.detailQcCriteria, "", {
variant: "qcCriteriaList",
qcCriteriaItems: group.map((q) => ({
name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem,
description: q.qcItemDescription?.trim() || undefined,
passed: q.qcPassed,
failQty: q.failQty > 0 ? q.failQty : undefined,
})),
}),
field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)),
field(labels.detailFailQty, formatQty(totalFail, matUom)),
field(labels.detailHandler, first.handledBy),
field(labels.detailTime, first.created),
fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")),
...pickOrderDetailFields(picks, labels.pickOrder),
),
});
});

let lastMaterialPutawayWh = "";
(m.putawayEvents ?? []).forEach((p) => {
const key = `${p.inventoryLotLineId}-${p.timestamp}`;
if (ctx.seenPutawayKeys.has(key)) return;
if (isTransferInboundPutaway(p.refType)) return;
ctx.seenPutawayKeys.add(key);
const linkKind = docLinkFromRefType(p.refType);
const pres = resolvePutawayPresentation(
labels.tr,
{
nodePutaway: labels.nodePutaway,
nodePutawayTransfer: labels.nodePutawayTransfer,
putawayTransferDetail: labels.putawayTransferDetail,
},
p.status,
p.refType,
);
const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · ");
if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim();
nodes.push({
id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`,
kind: "PUTAWAY",
timestamp: p.timestamp,
sortKey: parseSortKey(p.timestamp, nextSeq(ctx)),
title: pres.title,
subtitle: appendPickToSubtitle(
[pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") ||
m.materialItemCode,
picks,
labels.pickOrder,
),
qty: p.qty,
uom: matUom,
meta: meta || undefined,
refType: p.refType,
refCode: p.refCode,
refId: p.refId,
docLinkKind: linkKind,
putawayStatusLabel: pres.chipLabel,
putawayStatusDetail: pres.statusDetail,
warehouseCode: p.warehouseCode,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryPutaway,
details: [
field(labels.detailStatus, pres.statusDetail),
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
field(
isTransferInboundPutaway(p.refType)
? labels.detailInboundSiNo
: (p.refType ?? "").trim().toUpperCase() === "PO"
? labels.detailPurchaseOrderNo
: (p.refType ?? "").trim().toUpperCase() === "JO"
? labels.jobOrder
: labels.detailSourceDoc,
p.refCode,
{
linkKind,
linkCode: p.refCode,
linkId: p.refId,
}),
field(labels.detailPutawayBin, p.warehouseCode),
field(labels.detailQty, formatQty(p.qty, matUom)),
field(labels.detailHandler, p.handledBy),
field(labels.detailTime, p.timestamp),
...pickOrderDetailFields(picks, labels.pickOrder),
],
});
});

// ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」.
if (origin && lotId != null) {
const originType = origin.type.trim().toUpperCase();
if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) {
ctx.seenInboundLots.add(lotId);
const adjWh = lastMaterialPutawayWh;
const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · ");
// Place at/after related putaway so chronological X and 上架 → 調整 edges match FG.
const lastPutawayTs = (m.putawayEvents ?? [])
.map((p) => p.timestamp)
.filter((t): t is string => Boolean(t?.trim()))
.sort()
.at(-1);
const adjTimestamp =
lastPutawayTs &&
parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0)
? lastPutawayTs
: origin.receiptDate;
// Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整
// (same calendar time previously ordered adj left of putaway → back-arrow).
const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null;
const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx));
nodes.push({
id: `mat-adj-${lotId}-${origin.stockInLineId}`,
kind: "ADJUSTMENT",
timestamp: origin.receiptDate,
sortKey:
putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase,
title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`,
subtitle: appendPickToSubtitle(
[labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo]
.filter(Boolean)
.join(" · ") || m.materialItemCode,
picks,
labels.pickOrder,
),
qty: origin.acceptedQty,
uom: matUom,
adjustmentDirection: "IN",
meta: meta || undefined,
refType: origin.type,
refCode: origin.refCode,
refId: origin.refId,
warehouseCode: adjWh || undefined,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryAdjustment,
details: detailsOf(
field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)),
field(labels.detailAdjustmentRef, origin.refCode),
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []),
field(labels.detailStatus, labels.tr.stockInStatus(origin.status)),
field(labels.detailTime, origin.receiptDate),
...pickOrderDetailFields(picks, labels.pickOrder),
),
});
}
}

return nodes;
};

const buildNestedJoCreatedNode = (
m: ItemLotTraceMaterialInput,
labels: JoPreludeGraphLabels,
picks: LotPickRef[],
ctx: PreludeBuildContext,
): TraceGraphNode | null => {
const nested = m.nestedJoPrelude;
const jo = nested?.jobOrder;
if (!jo?.jobOrderCode?.trim()) return null;

const nestedInputs = nested?.materialInputs ?? [];
const nestedPickTs = nestedInputs
.map((input) => input.pickedAt)
.find((ts) => ts?.trim());
const nestedProdTs = (m.productionSteps ?? [])
.map((step) => step.startTime)
.find((ts) => ts?.trim());
const origin = m.stockInOrigin;
const ts =
jo.createdAt?.trim() ||
jo.planStart?.trim() ||
nestedPickTs?.trim() ||
nestedProdTs?.trim() ||
origin?.receiptDate?.trim() ||
null;
const matUom = m.materialUom?.trim() || "";
const qty = origin?.acceptedQty ?? jo.reqQty;

return {
id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`,
kind: "JO_CREATED",
timestamp: ts,
sortKey: parseSortKey(ts, nextSeq(ctx)),
title: labels.nodeJoCreated,
subtitle: appendPickToSubtitle(
[m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode,
picks,
labels.pickOrder,
),
qty,
uom: matUom,
refType: "JO",
refCode: jo.jobOrderCode,
refId: jo.jobOrderId,
docLinkKind: "jo",
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
categoryLabel: labels.categoryProduction,
details: [
field(labels.detailType, labels.nodeJoCreated),
field(labels.jobOrder, jo.jobOrderCode, {
linkKind: "jo",
linkCode: jo.jobOrderCode,
linkId: jo.jobOrderId,
}),
field(labels.detailItemCode, m.materialItemCode),
field(labels.detailLot, m.materialLotNo),
field(labels.detailQty, formatQty(qty, matUom)),
field(labels.detailTime, ts),
...pickOrderDetailFields(picks, labels.pickOrder),
],
};
};

const buildMaterialInputChainNodes = (
materialInputs: ItemLotTraceMaterialInput[],
labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
lotPicks: Map<string, LotPickRef[]>,
ctx: PreludeBuildContext,
): TraceGraphNode[] => {
const nodes: TraceGraphNode[] = [];

materialInputs.forEach((m) => {
const joProduced = isJoProducedMaterial(m);
const nestedInputs = m.nestedJoPrelude?.materialInputs ?? [];
const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo);

if (joProduced && m.nestedJoPrelude) {
const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx);
if (nestedJoCreated) nodes.push(nestedJoCreated);
}

if (joProduced && nestedInputs.length > 0) {
const nestedLotPicks = buildLotPickOrderMap(nestedInputs);
nodes.push(
...buildMaterialInputChainNodes(
nestedInputs,
labels,
nestedLotPicks,
ctx,
),
);
nestedInputs.forEach((nm, ni) => {
nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo));
});
}

if (joProduced && (m.productionSteps?.length ?? 0) > 0) {
nodes.push(...buildMaterialProductionNodes(m, labels, ctx));
}

nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx));
});

return nodes;
};

export const buildJoPreludeGraphNodes = (
joPrelude: ItemLotTraceJoPrelude,
labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
): TraceGraphNode[] => {
const ctx: PreludeBuildContext = {
seenPurchaseLines: new Set(),
seenInboundLots: new Set(),
seenReceiptLots: new Set(),
seenQcKeys: new Set(),
seenPutawayKeys: new Set(),
pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude),
seq: 0,
};
const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs);

const nodes: TraceGraphNode[] = [
...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx),
...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))),
];

return nodes.sort((a, b) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
return a.id.localeCompare(b.id);
});
};

+ 172
- 0
src/components/ItemTracing/buildLocationBlockGraphNodes.ts Ver arquivo

@@ -0,0 +1,172 @@
import { ItemLotTraceLocationBlock, ItemLotTraceResponse } from "@/app/api/itemTracing";

import {

buildTraceGraphNodes,

TraceGraphBuildScope,

TraceGraphDetailLabels,

TraceGraphNode,

} from "./buildTraceGraphNodes";

import {

buildExtendedTraceGraphNodes,

ExtendedTraceGraphLabels,

} from "./buildExtendedTraceGraphNodes";



export const blockWarehouseCode = (block: ItemLotTraceLocationBlock): string =>

block.warehouseLines

.map((w) => w.warehouseCode)

.filter(Boolean)

.join(" / ") || `Lot #${block.inventoryLotId}`;



const blockToTraceResponse = (block: ItemLotTraceLocationBlock): ItemLotTraceResponse => {

const uom = block.uom ?? "";

return {

lot: {

inventoryLotId: block.inventoryLotId,

lotNo: block.lotNo,

itemId: 0,

itemCode: block.itemCode,

itemName: block.itemName ?? "",

expiryDate: block.expiryDate ?? null,

productionDate: block.productionDate ?? null,

stockInDate: block.stockInDate ?? null,

uom,

primaryStockInLineId: block.stockInLineId,

},

warehouseLines: block.warehouseLines,

origins: block.origins,

purchaseEvents: block.purchaseEvents ?? [],

qcResults: block.qcResults ?? [],

putawayEvents: block.putawayEvents ?? [],

movements: block.movements ?? [],

outboundUsage: block.outboundUsage ?? [],

stockTakeEvents: block.stockTakeEvents ?? [],

adjustments: block.adjustments ?? [],

transfers: block.transfers ?? [],

bomTrace: { direction: "UNKNOWN", upstream: [], downstream: [], bomRecipe: [] },

joPrelude: null,

productionSteps: [],

byproductLots: [],

openMovements: block.openMovements ?? [],

failEvents: block.failEvents ?? [],

doDeliveries: block.doDeliveries ?? [],

replenishmentEvents: block.replenishmentEvents ?? [],

returnEvents: block.returnEvents ?? [],

lotRelations: [],

alternateLocations: [],

locationBlocks: [],

traceGraph: null,

};

};



export const buildLocationBlockGraphNodes = (

block: ItemLotTraceLocationBlock,

labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,

): TraceGraphNode[] => {

const wh = blockWarehouseCode(block);

const scope: TraceGraphBuildScope = {
idPrefix: `loc-${block.inventoryLotId}-`,
locationBlockKeys: true,
inventoryLotId: block.inventoryLotId,
defaultWarehouseCode: wh,
mergedMultiLocation: true,
};

const synthetic = blockToTraceResponse(block);

const fgNodes = buildTraceGraphNodes(synthetic, labels, scope);

const extNodes =

labels.nodeOpen && labels.nodeFail && labels.nodeDoOut

? buildExtendedTraceGraphNodes(synthetic, labels, scope)

: [];



return [...fgNodes, ...extNodes].sort((a, b) => {

if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;

return a.id.localeCompare(b.id);

});

};



export const buildAllLocationBlockGraphNodes = (

blocks: ItemLotTraceLocationBlock[],

labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,

): TraceGraphNode[] =>

(blocks ?? []).flatMap((block) => buildLocationBlockGraphNodes(block, labels));


+ 169
- 0
src/components/ItemTracing/buildProductionGraphNodes.ts Ver arquivo

@@ -0,0 +1,169 @@
import {
ItemLotTraceByproductLot,
ItemLotTraceProductionStep,
} from "@/app/api/itemTracing";
import {
TraceGraphDetailLabels,
TraceGraphNode,
} from "./buildTraceGraphNodes";
import { formatQty } from "./traceQtyUtils";
import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory";

export interface ProductionGraphLabels extends TraceGraphDetailLabels {
nodeProductionStep: string;
nodeByproduct: string;
nodeScrap: string;
nodeDefect: string;
detailProcessOutputQty: string;
detailProcessScrapQty: string;
detailProcessDefectQty: string;
}

const formatStepMaterials = (
materials: ItemLotTraceProductionStep["stepMaterials"],
): string => {
if (!materials?.length) return "—";
return materials
.map((mat, i) => {
const label = [mat.materialItemCode, mat.materialItemName].filter(Boolean).join(" · ");
const qty = formatQty(mat.qtyPerUnit, mat.uom);
return `${i + 1}. ${label} (${qty})`;
})
.join("\n");
};

export const buildProductionGraphNodes = (
productionSteps: ItemLotTraceProductionStep[],
byproductLots: ItemLotTraceByproductLot[],
labels: ProductionGraphLabels,
lotUom: string,
): TraceGraphNode[] => {
const nodes: TraceGraphNode[] = [];
let seq = 0;

productionSteps.forEach((step) => {
const ts = step.endTime ?? step.startTime;
const equip = [step.equipmentCode, step.equipmentName].filter(Boolean).join(" ");
const stepMaterialsLabel = formatStepMaterials(step.stepMaterials);
const materialCodesList = (step.stepMaterials ?? [])
.map((mat) => mat.materialItemCode?.trim())
.filter((code): code is string => Boolean(code));
const materialCodes = materialCodesList.join(" · ");
nodes.push({
id: `prod-step-${step.processLineId}`,
kind: "PRODUCTION_STEP",
timestamp: ts,
sortKey: parseSortKey(ts, seq++),
title: step.stepName || labels.nodeProductionStep,
subtitle: [materialCodes, equip, labels.tr.productionStatus(step.status)]
.filter(Boolean)
.join(" · ") || "—",
qty: step.outputQty,
uom: lotUom,
meta: step.operatorName ? `${labels.detailHandler}: ${step.operatorName}` : undefined,
refType: "JO",
bomProcessId: step.bomProcessId,
bomProcessSeqNo: step.seqNo,
assignedStepName: step.stepName,
stepMaterialItemCodes: materialCodesList.length ? materialCodesList : undefined,
categoryLabel: labels.categoryProduction,
details: detailsOf(
field(labels.detailType, labels.nodeProductionStep),
field(labels.detailStatus, labels.tr.productionStatus(step.status)),
field(labels.detailProcessStep, step.stepName),
field(labels.detailStepMaterials, stepMaterialsLabel),
fieldIf(labels.detailProcessDescription, step.description),
field(labels.detailHandler, step.operatorName),
field(labels.detailTime, ts),
field(labels.detailProcessOutputQty, formatQty(step.outputQty, lotUom)),
field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)),
field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)),
field(labels.detailEquipment, equip || "—"),
),
});

const lossQty = (step.scrapQty ?? 0) + (step.defectQty ?? 0);
if (lossQty > 0) {
if ((step.scrapQty ?? 0) > 0) {
nodes.push({
id: `scrap-${step.processLineId}`,
kind: "SCRAP",
timestamp: ts,
sortKey: parseSortKey(ts, seq++) + 1,
title: labels.nodeScrap,
subtitle: step.stepName || "—",
qty: step.scrapQty,
uom: lotUom,
bomProcessId: step.bomProcessId,
bomProcessSeqNo: step.seqNo,
assignedStepName: step.stepName,
categoryLabel: labels.categoryProduction,
details: [
field(labels.detailType, labels.nodeScrap),
field(labels.detailProcessStep, step.stepName),
field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)),
field(labels.detailTime, ts),
],
});
}
if ((step.defectQty ?? 0) > 0) {
nodes.push({
id: `defect-${step.processLineId}`,
kind: "DEFECT",
timestamp: ts,
sortKey: parseSortKey(ts, seq++) + 2,
title: labels.nodeDefect,
subtitle: step.stepName || "—",
qty: step.defectQty,
uom: lotUom,
bomProcessId: step.bomProcessId,
bomProcessSeqNo: step.seqNo,
assignedStepName: step.stepName,
categoryLabel: labels.categoryProduction,
details: [
field(labels.detailType, labels.nodeDefect),
field(labels.detailProcessStep, step.stepName),
field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)),
field(labels.detailTime, ts),
],
});
}
}
});

byproductLots.forEach((bp, i) => {
const subtitle = [bp.lotNo, bp.processStepName].filter(Boolean).join(" · ") || bp.itemName;
nodes.push({
id: `byproduct-${bp.inventoryLotId ?? i}-${bp.lotNo}`,
kind: "BYPRODUCT",
timestamp: bp.producedAt,
sortKey: parseSortKey(bp.producedAt, seq++),
title: `${bp.itemCode} · ${labels.nodeByproduct}`,
subtitle,
qty: bp.qty,
uom: bp.uom,
refType: "JO",
refCode: bp.jobOrderCode,
refId: bp.jobOrderId,
docLinkKind: "jo",
traceLotNo: bp.lotNo,
traceItemCode: bp.itemCode,
categoryLabel: labels.categoryProduction,
details: [
field(labels.detailType, labels.nodeByproduct),
field(labels.detailMaterial, `${bp.itemCode} · ${bp.itemName}`),
field(labels.detailLot, bp.lotNo),
field(labels.detailQty, formatQty(bp.qty, bp.uom)),
field(labels.detailTime, bp.producedAt),
field(labels.jobOrder, bp.jobOrderCode, {
linkKind: "jo",
linkCode: bp.jobOrderCode,
linkId: bp.jobOrderId,
}),
field(labels.detailProcessStep, bp.processStepName),
],
});
});

return nodes;
};

+ 323
- 0
src/components/ItemTracing/buildReactFlowGraph.ts Ver arquivo

@@ -0,0 +1,323 @@
import { MarkerType, type Edge, type Node } from "@xyflow/react";
import {
COLUMN_WIDTH_MIN,
HEADER_HEIGHT,
LANE_GAP,
LANE_MIN_HEIGHT,
NODE_HEIGHT,
NODE_WIDTH,
NODE_WIDTH_BRANCH,
PHASE_LABEL_WIDTH,
} from "./traceFlowConstants";
import {
CELL_PAD_X,
layoutCellMembers,
computeLaneTops,
} from "./traceFlowLayout";
import { phaseAccent } from "./traceFlowNodeUtils";
import { TraceGraphLayout, TraceGraphLayoutNode, buildTraceFlowEdgePairs } from "./traceGraphLayout";
import type { TraceFlowEdgePair } from "./traceGraphSemantics";
import {
assignTraceFlowEdgeRouting,
enrichTraceFlowCorridorRouting,
type TraceFlowNodeRect,
} from "./traceFlowEdgeLayout";
import {
isDoGroupChild,
isFlowGroupContainer,
layoutDoGroupChildPlacements,
} from "./traceDoGroupLayout";

export type TraceFlowNodeData = {
layoutNode: TraceGraphLayoutNode;
compact: boolean;
onTrace?: (params: { stockInLineId?: number; lotNo?: string; itemCode?: string }) => void;
phaseLabel?: string;
dateLabel?: string;
phaseColor?: string;
incomingHandleCount?: number;
outgoingHandleCount?: number;
searchActive?: boolean;
searchMatch?: boolean;
searchFocused?: boolean;
nodeSelected?: boolean;
/** DO / pick group header collapse. */
groupCollapsed?: boolean;
onToggleGroupCollapse?: () => void;
};

const cellKey = (laneIndex: number, column: number, stagger: number) =>
`${laneIndex}:${column}:${stagger}`;

export const buildReactFlowGraph = (
layout: TraceGraphLayout,
phaseLabels: Record<string, string>,
edgePairs?: TraceFlowEdgePair[],
): { nodes: Node<TraceFlowNodeData>[]; edges: Edge[]; graphHeight: number } => {
const nodes: Node<TraceFlowNodeData>[] = [];
const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n));

const cells = new Map<string, TraceGraphLayoutNode[]>();
topLevelNodes.forEach((n) => {
const key = cellKey(n.laneIndex, n.column, n.dayPhaseStaggerIndex);
const list = cells.get(key) ?? [];
list.push(n);
cells.set(key, list);
});

const cellLayouts = new Map<string, ReturnType<typeof layoutCellMembers>>();
const { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts } = layout.dayColumns;

cells.forEach((members, key) => {
const col = Number(key.split(":")[1]);
const stagger = members[0]?.dayPhaseStaggerIndex ?? 0;
const slotW = phaseSlotWidths[col]?.[stagger] ?? COLUMN_WIDTH_MIN;
cellLayouts.set(key, layoutCellMembers(members, slotW - CELL_PAD_X * 2));
});

const doChildrenByGroup = new Map<string, TraceGraphLayoutNode[]>();
layout.nodes.forEach((n) => {
if (!n.doGroupId) return;
const list = doChildrenByGroup.get(n.doGroupId) ?? [];
list.push(n);
doChildrenByGroup.set(n.doGroupId, list);
});

const childPlacementsByGroup = new Map<
string,
ReturnType<typeof layoutDoGroupChildPlacements>
>();
doChildrenByGroup.forEach((children, groupId) => {
childPlacementsByGroup.set(groupId, layoutDoGroupChildPlacements(children));
});

const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells);

layout.columnDates.forEach((date, col) => {
const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN;
const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN;
nodes.push({
id: `hdr-${col}`,
type: "dateHeader",
position: {
x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40,
y: 6,
},
data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date },
draggable: false,
selectable: false,
connectable: false,
focusable: false,
});
});

layout.phaseOrder.forEach((phase, laneIdx) => {
const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT;
nodes.push({
id: `phase-${phase}`,
type: "phaseLabel",
position: {
x: 8,
y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2),
},
data: {
layoutNode: {} as TraceGraphLayoutNode,
compact: false,
phaseLabel: phaseLabels[phase] ?? phase,
phaseColor: phaseAccent(phase),
},
draggable: false,
selectable: false,
connectable: false,
focusable: false,
});
});

topLevelNodes.forEach((layoutNode) => {
const key = cellKey(layoutNode.laneIndex, layoutNode.column, layoutNode.dayPhaseStaggerIndex);
const placed = cellLayouts.get(key)?.get(layoutNode.id);
if (!placed) return;

const dayStart = columnStarts[layoutNode.column] ?? layoutNode.column * COLUMN_WIDTH_MIN;
const phaseSlotX =
phaseSlotStarts[layoutNode.column]?.[layoutNode.dayPhaseStaggerIndex] ?? 0;
const isGroup = isFlowGroupContainer(layoutNode);
const groupW = layoutNode.groupBoxWidth ?? NODE_WIDTH;
const groupH = layoutNode.groupBoxHeight ?? NODE_HEIGHT;

nodes.push({
id: layoutNode.id,
type: layoutNode.kind === "PICK_GROUP" ? "pickGroup" : isGroup ? "doGroup" : "traceEvent",
position: {
x: PHASE_LABEL_WIDTH + dayStart + phaseSlotX + CELL_PAD_X + placed.x,
y: laneTops[layoutNode.laneIndex] + placed.y,
},
data: { layoutNode, compact: placed.compact },
draggable: false,
connectable: false,
selectable: !isGroup,
zIndex: isGroup ? 0 : 1,
style: isGroup ? { width: groupW, height: groupH } : undefined,
width: isGroup ? groupW : undefined,
height: isGroup ? groupH : undefined,
measured: {
width: isGroup ? groupW : placed.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH,
height: isGroup ? groupH : NODE_HEIGHT,
},
});
});

const parentPositions = new Map<string, { x: number; y: number }>();
nodes.forEach((node) => {
if (node.type === "doGroup" || node.type === "pickGroup") {
parentPositions.set(node.id, node.position);
}
});

doChildrenByGroup.forEach((children, groupId) => {
const placements = childPlacementsByGroup.get(groupId);
const parentPos = parentPositions.get(groupId);
if (!placements || !parentPos) return;

children.forEach((layoutNode) => {
const placed = placements.get(layoutNode.id);
if (!placed) return;

nodes.push({
id: layoutNode.id,
type: "traceEvent",
position: {
x: parentPos.x + placed.x,
y: parentPos.y + placed.y,
},
data: { layoutNode, compact: placed.compact },
draggable: false,
connectable: false,
zIndex: 2,
width: NODE_WIDTH,
height: NODE_HEIGHT,
measured: {
width: NODE_WIDTH,
height: NODE_HEIGHT,
},
});
});
});

const flowPairs = edgePairs ?? buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder);
const { routed, incomingCount, outgoingCount } = assignTraceFlowEdgeRouting(flowPairs);

const nodeRects = new Map<string, TraceFlowNodeRect>();
nodes.forEach((node) => {
if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return;
const layoutNode = node.data.layoutNode;
if (layoutNode.doGroupId) return;
nodeRects.set(node.id, {
x: node.position.x,
y: node.position.y,
width: node.measured?.width ?? NODE_WIDTH,
height: node.measured?.height ?? NODE_HEIGHT,
laneIndex: layoutNode.laneIndex,
});
});

const corridorRouted = enrichTraceFlowCorridorRouting(routed, nodeRects, {
laneTops,
laneHeights,
laneGap: LANE_GAP,
});

const nodesWithHandles = nodes.map((node) => {
if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return node;
if (node.data.layoutNode.doGroupId) {
return {
...node,
data: {
...node.data,
incomingHandleCount: 0,
outgoingHandleCount: 0,
},
};
}
return {
...node,
data: {
...node.data,
incomingHandleCount: incomingCount.get(node.id) ?? 0,
outgoingHandleCount: outgoingCount.get(node.id) ?? 0,
},
};
});

const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));

const edges: Edge[] = corridorRouted.map(
({
fromId,
toId,
sourceHandle,
targetHandle,
offset,
pathMode,
corridorY,
corridorEntryOffset,
corridorExitOffset,
corridorBranchOffset,
}) => {
const targetNode = nodeById.get(toId);
const ref = targetNode?.refCode?.trim();
const flowLabel =
targetNode?.kind === "DO_GROUP" && targetNode.groupMemberCount
? String(targetNode.groupMemberCount)
: targetNode?.kind === "PICK_GROUP" && ref
? ref
: targetNode?.kind === "MATERIAL_PICK" && ref
? ref
: (targetNode?.kind === "OUT" || targetNode?.kind === "DO_OUT") && ref
? ref
: undefined;
return {
id: `e-${fromId}-${toId}`,
source: fromId,
target: toId,
sourceHandle,
targetHandle,
type: "traceFlow",
data: {
offset,
pathMode,
corridorY,
corridorEntryOffset,
corridorExitOffset,
corridorBranchOffset,
},
label: flowLabel,
labelStyle: { fontSize: 10, fontWeight: 600, fill: "#5d4037" },
labelBgStyle: { fill: "#fff8e1", fillOpacity: 0.95 },
labelBgPadding: [4, 6] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "#757575", strokeWidth: 1.5, opacity: 0.75 },
markerEnd: { type: MarkerType.ArrowClosed, color: "#757575", width: 16, height: 16 },
};
});

return { nodes: nodesWithHandles, edges, graphHeight: totalHeight };
};

export const reactFlowGraphExtent = (
layout: TraceGraphLayout,
graphHeight?: number,
): [[number, number], [number, number]] => {
const width = PHASE_LABEL_WIDTH + layout.dayColumns.timelineWidth + 32;
const height = graphHeight ?? HEADER_HEIGHT + layout.laneCount * LANE_MIN_HEIGHT + 32;
// Extra bottom room so expanded stock-take lifecycle panels stay pannable.
const padX = 48;
const padTop = 48;
const padBottom = 360;
return [
[-padX, -padTop],
[width + padX, height + padBottom],
];
};

export type ReactFlowGraphResult = ReturnType<typeof buildReactFlowGraph>;

+ 1223
- 0
src/components/ItemTracing/buildTraceGraphNodes.ts
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 33
- 0
src/components/ItemTracing/compileTraceGraph.ts Ver arquivo

@@ -0,0 +1,33 @@
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import {
buildTraceGraphLayout,
TraceGraphLayout,
TraceGraphLayoutNode,
} from "./traceGraphLayout";
import { resolveTraceFlowEdgePairs } from "./traceGraphSemantics";
import type { TraceGraphCompileLabels } from "./traceGraphLabels";

export type TraceFlowEdgePair = { fromId: string; toId: string };

export type CompiledTraceGraph = {
layout: TraceGraphLayout;
edgePairs: TraceFlowEdgePair[];
nodes: TraceGraphLayoutNode[];
};

export const compileTraceGraph = (
data: ItemLotTraceResponse,
labels: TraceGraphCompileLabels,
): CompiledTraceGraph => {
const layout = buildTraceGraphLayout(data, labels);
const backendEdges = data.traceGraph?.edges?.map((e) => ({
fromKey: e.fromKey,
toKey: e.toKey,
}));
const edgePairs = resolveTraceFlowEdgePairs(layout.nodes, layout.phaseOrder, backendEdges);
return {
layout,
edgePairs,
nodes: layout.nodes,
};
};

+ 905
- 0
src/components/ItemTracing/exportItemLotTraceXlsx.ts Ver arquivo

@@ -0,0 +1,905 @@
import type { TFunction } from "i18next";
import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
import {
exportMultiSheetToXlsx,
type MultiSheetSpec,
} from "@/app/(main)/chart/_components/exportChartToXlsx";
import type { CompiledTraceGraph } from "./compileTraceGraph";
import {
mergeScopedAdjustments,
mergeScopedDoDeliveries,
mergeScopedFailEvents,
mergeScopedOpenMovements,
mergeScopedOrigins,
mergeScopedOutboundUsage,
mergeScopedPurchaseEvents,
mergeScopedPutawayEvents,
mergeScopedQcResults,
mergeScopedReplenishmentEvents,
mergeScopedReturnEvents,
mergeScopedStockTakeEvents,
mergeScopedTransfers,
} from "./mergeLocationScopedData";
import {
resolveStockTakeAcceptedQty,
resolveStockTakeBookQty,
} from "./traceStockTakeUtils";
import {
createTraceLabelTranslator,
type TraceLabelTranslator,
} from "./traceLabelUtils";
import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils";
import { buildTracePresentationRows } from "./tracePresentationAdapter";
import { isDoGroupChild } from "./traceDoGroupLayout";

const NO_DATA = "(無資料 / No records)";

const SHEET = {
summary: "摘要 Summary",
timeline: "生命週期時間軸 Timeline",
origins: "來源與採購 Origins",
qc: "品質檢驗 QC",
warehouse: "倉儲作業 Warehouse",
outbound: "出庫 Outbound",
stockTake: "盤點 Stock Take",
production: "生產與BOM Production",
} as const;

export type ItemLotTraceExportLabels = {
bomDirectionFg: string;
bomDirectionMaterial: string;
bomDirectionUnknown: string;
};

const orDash = (v: string | number | null | undefined): string | number => {
if (v == null) return "—";
if (typeof v === "string" && !v.trim()) return "—";
return v;
};

const bomDirectionLabel = (
direction: string,
labels: ItemLotTraceExportLabels,
): string => {
const d = direction.trim().toUpperCase();
if (d === "FINISHED_GOOD") return labels.bomDirectionFg;
if (d === "MATERIAL") return labels.bomDirectionMaterial;
return labels.bomDirectionUnknown;
};

const ensureRows = (
rows: Record<string, unknown>[],
emptyTemplate: Record<string, unknown>,
): Record<string, unknown>[] => {
if (rows.length > 0) return rows;
const firstKey = Object.keys(emptyTemplate)[0];
return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }];
};

const todayYmd = (): string => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}${m}${day}`;
};

const safeFilenamePart = (s: string): string =>
s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown";

export const buildItemLotTraceFilename = (
data: ItemLotTraceResponse,
): string => {
const item = safeFilenamePart(data.lot.itemCode || "item");
const lot = safeFilenamePart(data.lot.lotNo || "lot");
return `批號追溯_${item}_${lot}_${todayYmd()}`;
};

const summaryEmpty = (): Record<string, unknown> => ({
"Field / 欄位": "",
"Value / 值": "",
});

const buildSummarySheet = (
data: ItemLotTraceResponse,
labels: ItemLotTraceExportLabels,
exportAt: string,
): Record<string, unknown>[] => {
const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
const totalAvailable = warehouseLines.reduce(
(s, w) => s + (w.availableQty ?? 0),
0,
);
const rows: Record<string, unknown>[] = [
{ "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) },
{ "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) },
{ "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) },
{ "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) },
{ "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) },
{
"Field / 欄位": "Production Date / 生產日期",
"Value / 值": orDash(lot.productionDate),
},
{
"Field / 欄位": "Stock In Date / 入庫日期",
"Value / 值": orDash(lot.stockInDate),
},
{
"Field / 欄位": "Total Available / 總可用量",
"Value / 值": totalAvailable,
},
{
"Field / 欄位": "BOM Direction / BOM 方向",
"Value / 值": bomDirectionLabel(bomTrace.direction, labels),
},
];

if (joPrelude) {
const jo = joPrelude.jobOrder;
rows.push(
{
"Field / 欄位": "Job Order / 工單",
"Value / 值": orDash(jo.jobOrderCode),
},
{
"Field / 欄位": "Plan Start / 計劃開工",
"Value / 值": orDash(jo.planStart),
},
{ "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty },
{
"Field / 欄位": "JO Status / 工單狀態",
"Value / 值": orDash(jo.status),
},
);
}

rows.push(
{ "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt },

{ "Field / 欄位": "", "Value / 值": "" },
{
"Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —",
"Value / 值": "",
},
);

warehouseLines.forEach((w) => {
rows.push({
"Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`,
"Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`,
});
});

if (alternateLocations.length > 0) {
rows.push(
{ "Field / 欄位": "", "Value / 值": "" },
{
"Field / 欄位": "— Alternate Locations / 其他倉位 —",
"Value / 值": "",
},
);
alternateLocations.forEach((loc) => {
rows.push({
"Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`,
"Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`,
});
});
}

return rows.length > 0 ? rows : [summaryEmpty()];
};

const timelineEmpty = (): Record<string, unknown> => ({
"Date/Time / 時間": "",
"Phase / 階段": "",
"Event Type / 事件類型": "",
"Doc No. / 單號": "",
"Ref Type / 來源類型": "",
"Ref Type Code": "",
"Qty / 數量": "",
"UOM / 單位": "",
"Warehouse / 倉位": "",
"Handler / 經手人": "",
"Lot / 批號": "",
"Remarks / 備註": "",
});

const buildTimelineSheet = (
compiledGraph: CompiledTraceGraph,
t: TFunction,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const nodes = compiledGraph.layout.nodes
.filter(
(n) =>
n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n),
)
.slice()
.sort((a, b) => {
const ta = a.timestamp ?? "";
const tb = b.timestamp ?? "";
if (ta !== tb) return ta.localeCompare(tb);
return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex;
});

const rows = nodes.map((n) => {
const handlerDetail = n.details.find(
(d) =>
d.label.toLowerCase().includes("handler") ||
d.label.includes("經手") ||
d.label.includes("核准") ||
d.label.includes("盤點"),
);
return {
"Date/Time / 時間": orDash(n.timestamp),
"Phase / 階段": t(phaseLabelKey(n.phase)),
"Event Type / 事件類型": t(kindLabelKey(n.kind)),
"Doc No. / 單號": orDash(n.refCode),
"Ref Type / 來源類型": tr.refType(n.refType),
"Ref Type Code": orDash(n.refType),
"Qty / 數量": n.qty ?? "",
"UOM / 單位": orDash(n.uom),
"Warehouse / 倉位": orDash(n.warehouseCode),
"Handler / 經手人": orDash(
handlerDetail?.value ?? n.meta?.split(" · ")[0],
),
"Lot / 批號": orDash(n.traceLotNo),
"Remarks / 備註": orDash(n.subtitle),
};
});
return ensureRows(rows, timelineEmpty());
};

const originsEmpty = (): Record<string, unknown> => ({
"Section / 區塊": "",
"Warehouse / 倉位": "",
"Inventory Lot Id": "",
"Type / 類型": "",
"Type Code": "",
"Doc No. / 單號": "",
"Supplier Code / 供應商編號": "",
"Supplier / 供應商": "",
"DN No. / 送貨單號": "",
"Qty / 數量": "",
"Status / 狀態": "",
"Date / 日期": "",
});

const buildOriginsSheet = (
data: ItemLotTraceResponse,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const originRows = mergeScopedOrigins(data).map((o) => ({
"Section / 區塊": "Origin / 來源",
"Warehouse / 倉位": o.scopeWarehouseCode,
"Inventory Lot Id": o.inventoryLotId,
"Type / 類型": tr.refType(o.type),
"Type Code": orDash(o.type),
"Doc No. / 單號": orDash(o.refCode),
"Supplier Code / 供應商編號": orDash(o.supplierCode),
"Supplier / 供應商": orDash(o.supplierName),
"DN No. / 送貨單號": orDash(o.dnNo),
"Qty / 數量": o.acceptedQty,
"Status / 狀態": tr.stockInStatus(o.status),
"Date / 日期": orDash(o.receiptDate),
}));

const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({
"Section / 區塊": "Purchase / 採購",
"Warehouse / 倉位": p.scopeWarehouseCode,
"Inventory Lot Id": p.inventoryLotId,
"Type / 類型": tr.refType("PO"),
"Type Code": "PO",
"Doc No. / 單號": orDash(p.purchaseOrderCode),
"Supplier Code / 供應商編號": orDash(p.supplierCode),
"Supplier / 供應商": orDash(p.supplierName),
"DN No. / 送貨單號": "",
"Qty / 數量": p.orderQty,
"Status / 狀態": orDash(p.status),
"Date / 日期": orDash(p.orderDate),
}));

return ensureRows([...originRows, ...purchaseRows], originsEmpty());
};

const qcEmpty = (): Record<string, unknown> => ({
"Warehouse / 倉位": "",
"Inventory Lot Id": "",
"QC Result / 結果": "",
"QC Type / 品檢類型": "",
"QC Type Code": "",
"QC Item Code / 品檢項編號": "",
"QC Item / 品檢項": "",
"Accepted Qty / 抽樣數量": "",
"Fail Qty / 不良數量": "",
"Remarks / 備註": "",
"Handler / 經手人": "",
"Date/Time / 時間": "",
"Stock In Line Id": "",
});

const buildQcSheet = (
data: ItemLotTraceResponse,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const rows = mergeScopedQcResults(data).map((q) => ({
"Warehouse / 倉位": q.scopeWarehouseCode,
"Inventory Lot Id": q.inventoryLotId,
"QC Result / 結果": tr.qcPassed(q.qcPassed),
"QC Type / 品檢類型": tr.qcType(q.qcType),
"QC Type Code": orDash(q.qcType),
"QC Item Code / 品檢項編號": orDash(q.qcItemCode),
"QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription),
"Accepted Qty / 抽樣數量": q.acceptedQty,
"Fail Qty / 不良數量": q.failQty,
"Remarks / 備註": orDash(q.remarks),
"Handler / 經手人": orDash(q.handledBy),
"Date/Time / 時間": orDash(q.created),
"Stock In Line Id": q.stockInLineId,
}));
return ensureRows(rows, qcEmpty());
};

const warehouseEmpty = (): Record<string, unknown> => ({
"Section / 區塊": "",
"Warehouse / 倉位": "",
"Inventory Lot Id": "",
"Direction / 方向": "",
"Type / 類型": "",
"Type Code": "",
"Doc No. / 單號": "",
"From / 來源倉": "",
"To / 目標倉": "",
"Qty / 數量": "",
"Handler / 經手人": "",
"Status / 狀態": "",
"Remarks / 備註": "",
"Date/Time / 時間": "",
});

const buildWarehouseSheet = (
data: ItemLotTraceResponse,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const putaway = mergeScopedPutawayEvents(data).map((p) => ({
"Section / 區塊": "Putaway / 上架",
"Warehouse / 倉位": p.scopeWarehouseCode,
"Inventory Lot Id": p.inventoryLotId,
"Direction / 方向": "",
"Type / 類型": tr.refType(p.refType),
"Type Code": orDash(p.refType),
"Doc No. / 單號": orDash(p.refCode),
"From / 來源倉": "",
"To / 目標倉": orDash(p.warehouseCode),
"Qty / 數量": p.qty,
"Handler / 經手人": orDash(p.handledBy),
"Status / 狀態": orDash(p.status),
"Remarks / 備註": "",
"Date/Time / 時間": orDash(p.timestamp),
}));

const transfers = mergeScopedTransfers(data).map((trRow) => ({
"Section / 區塊": "Transfer / 轉倉",
"Warehouse / 倉位": trRow.scopeWarehouseCode,
"Inventory Lot Id": trRow.inventoryLotId,
"Direction / 方向": "",
"Type / 類型": tr.refType("TRANSFER"),
"Type Code": "TRANSFER",
"Doc No. / 單號": orDash(trRow.transferCode),
"From / 來源倉": orDash(trRow.fromWarehouse),
"To / 目標倉": orDash(trRow.toWarehouse),
"Qty / 數量": trRow.qty,
"Handler / 經手人": "",
"Status / 狀態": "",
"Remarks / 備註": "",
"Date/Time / 時間": orDash(trRow.timestamp),
}));

const adjustments = mergeScopedAdjustments(data).map((a) => ({
"Section / 區塊": "Adjustment / 庫存調整",
"Warehouse / 倉位": a.scopeWarehouseCode,
"Inventory Lot Id": a.inventoryLotId,
"Direction / 方向": tr.direction(a.direction),
"Type / 類型": tr.adjustmentType(a.adjustmentType),
"Type Code": orDash(a.adjustmentType),
"Doc No. / 單號": orDash(a.refCode),
"From / 來源倉": "",
"To / 目標倉": orDash(a.warehouseCode),
"Qty / 數量": a.qty,
"Handler / 經手人": orDash(a.handledBy),
"Status / 狀態": "",
"Remarks / 備註": orDash(a.reason),
"Date/Time / 時間": orDash(a.timestamp),
}));

const openMoves = mergeScopedOpenMovements(data).map((o) => ({
"Section / 區塊": "Opening Stock / 開倉入庫",
"Warehouse / 倉位": o.scopeWarehouseCode,
"Inventory Lot Id": o.inventoryLotId,
"Direction / 方向": tr.direction("IN"),
"Type / 類型": tr.refType("OPEN"),
"Type Code": "OPEN",
"Doc No. / 單號": orDash(o.refCode),
"From / 來源倉": "",
"To / 目標倉": orDash(o.warehouseCode),
"Qty / 數量": o.qty,
"Handler / 經手人": orDash(o.handledBy),
"Status / 狀態": "",
"Remarks / 備註": orDash(o.remarks),
"Date/Time / 時間": orDash(o.timestamp),
}));

return ensureRows(
[...putaway, ...transfers, ...adjustments, ...openMoves],
warehouseEmpty(),
);
};

const outboundEmpty = (): Record<string, unknown> => ({
"Section / 區塊": "",
"Warehouse / 倉位": "",
"Inventory Lot Id": "",
"Usage Type / 類型": "",
"Usage Type Code": "",
"Doc No. / 單號": "",
"Pick Order / 提料單": "",
"Conso / 併單": "",
"Job Order / 工單": "",
"Delivery Order / 送貨單": "",
"Delivery Note / DN": "",
"Ticket / 票號": "",
"Shop / 門市": "",
"Qty / 數量": "",
"Handler / 經手人": "",
"Extra / 加單": "",
"Replenish / 補貨": "",
"Remarks / 備註": "",
"Date/Time / 時間": "",
});

const buildOutboundSheet = (
data: ItemLotTraceResponse,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const usage = mergeScopedOutboundUsage(data).map((u) => ({
"Section / 區塊": "Outbound Usage / 出庫使用",
"Warehouse / 倉位": u.scopeWarehouseCode,
"Inventory Lot Id": u.inventoryLotId,
"Usage Type / 類型": tr.usageType(u.usageType),
"Usage Type Code": orDash(u.usageType),
"Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode),
"Pick Order / 提料單": orDash(u.pickOrderCode),
"Conso / 併單": orDash(u.consoCode),
"Job Order / 工單": orDash(u.jobOrderCode),
"Delivery Order / 送貨單": orDash(u.deliveryOrderCode),
"Delivery Note / DN": "",
"Ticket / 票號": "",
"Shop / 門市": "",
"Qty / 數量": u.qty,
"Handler / 經手人": orDash(u.handler),
"Extra / 加單": "",
"Replenish / 補貨": "",
"Remarks / 備註": "",
"Date/Time / 時間": orDash(u.timestamp),
}));

const doRows = mergeScopedDoDeliveries(data).map((d) => ({
"Section / 區塊": "DO Delivery / 成品出倉",
"Warehouse / 倉位": d.scopeWarehouseCode,
"Inventory Lot Id": d.inventoryLotId,
"Usage Type / 類型": tr.usageType("FG_DELIVERY"),
"Usage Type Code": "FG_DELIVERY",
"Doc No. / 單號": orDash(d.deliveryOrderCode),
"Pick Order / 提料單": orDash(d.pickOrderCode),
"Conso / 併單": orDash(d.consoCode),
"Job Order / 工單": "",
"Delivery Order / 送貨單": orDash(d.deliveryOrderCode),
"Delivery Note / DN": orDash(d.deliveryNoteCode),
"Ticket / 票號": orDash(d.ticketNo),
"Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—",
"Qty / 數量": d.qty,
"Handler / 經手人": orDash(d.handler),
"Extra / 加單": d.isExtra ? "Y" : "",
"Replenish / 補貨": d.isReplenish ? "Y" : "",
"Remarks / 備註": "",
"Date/Time / 時間": orDash(d.timestamp),
}));

const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({
"Section / 區塊": "Replenishment / 補貨",
"Warehouse / 倉位": r.scopeWarehouseCode,
"Inventory Lot Id": r.inventoryLotId,
"Usage Type / 類型": "Replenishment",
"Usage Type Code": "REPLENISH",
"Doc No. / 單號": orDash(r.replenishmentCode),
"Pick Order / 提料單": "",
"Conso / 併單": "",
"Job Order / 工單": "",
"Delivery Order / 送貨單": orDash(r.sourceDoCode),
"Delivery Note / DN": "",
"Ticket / 票號": "",
"Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—",
"Qty / 數量": r.replenishQty,
"Handler / 經手人": orDash(r.handler),
"Extra / 加單": "",
"Replenish / 補貨": "Y",
"Remarks / 備註": orDash(r.reason),
"Date/Time / 時間": orDash(r.timestamp),
}));

const returns = mergeScopedReturnEvents(data).map((r) => ({
"Section / 區塊": "Return / 退貨",
"Warehouse / 倉位": r.scopeWarehouseCode,
"Inventory Lot Id": r.inventoryLotId,
"Usage Type / 類型": tr.movementType(r.movementType),
"Usage Type Code": orDash(r.movementType),
"Doc No. / 單號": orDash(r.refCode),
"Pick Order / 提料單": "",
"Conso / 併單": "",
"Job Order / 工單": "",
"Delivery Order / 送貨單": "",
"Delivery Note / DN": "",
"Ticket / 票號": "",
"Shop / 門市": "",
"Qty / 數量": r.qty,
"Handler / 經手人": orDash(r.handler),
"Extra / 加單": "",
"Replenish / 補貨": "",
"Remarks / 備註": orDash(r.remarks),
"Date/Time / 時間": orDash(r.timestamp),
}));

const fails = mergeScopedFailEvents(data).map((f) => ({
"Section / 區塊": "Pick Failure / 揀貨異常",
"Warehouse / 倉位": f.scopeWarehouseCode,
"Inventory Lot Id": f.inventoryLotId,
"Usage Type / 類型": tr.failType(f.failType),
"Usage Type Code": orDash(f.failType),
"Doc No. / 單號": orDash(f.pickOrderCode),
"Pick Order / 提料單": orDash(f.pickOrderCode),
"Conso / 併單": "",
"Job Order / 工單": "",
"Delivery Order / 送貨單": "",
"Delivery Note / DN": "",
"Ticket / 票號": "",
"Shop / 門市": "",
"Qty / 數量": f.qty,
"Handler / 經手人": orDash(f.handlerName),
"Extra / 加單": "",
"Replenish / 補貨": "",
"Remarks / 備註": orDash(f.category),
"Date/Time / 時間": orDash(f.recordDate),
}));

return ensureRows(
[...usage, ...doRows, ...replenish, ...returns, ...fails],
outboundEmpty(),
);
};

const stockTakeEmpty = (): Record<string, unknown> => ({
"Warehouse / 倉位": "",
"Inventory Lot Id": "",
"Stock Take Code / 盤點單號": "",
"Round / 輪次": "",
"Section / 區域": "",
"Lot / 批號": "",
"Book Qty / 帳面數量": "",
"Accepted Qty / 核准數量": "",
"Variance Qty / 差異數量": "",
"Approver / 核准人": "",
"Stock Taker / 初盤人": "",
"First Count Qty / 初盤數量": "",
"Second Count Qty / 複盤數量": "",
"Approver Count Qty / 覆盤數量": "",
"Record Status / 紀錄狀態": "",
"Date/Time / 時間": "",
});

const buildStockTakeSheet = (
data: ItemLotTraceResponse,
): Record<string, unknown>[] => {
const rows = mergeScopedStockTakeEvents(data).map((e) => {
const d = e.recordDetail;
return {
"Warehouse / 倉位": e.scopeWarehouseCode,
"Inventory Lot Id": e.inventoryLotId,
"Stock Take Code / 盤點單號": orDash(e.stockTakeCode),
"Round / 輪次": orDash(
e.stockTakeRoundName ??
(e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""),
),
"Section / 區域": orDash(e.stockTakeSection),
"Lot / 批號": orDash(e.lotNo),
"Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "",
"Accepted Qty / 核准數量":
resolveStockTakeAcceptedQty(d, e.afterQty) ?? "",
"Variance Qty / 差異數量":
d?.varianceQty != null ? d.varianceQty : e.varianceQty,
"Approver / 核准人": orDash(d?.approverName || e.approver),
"Stock Taker / 初盤人": orDash(d?.stockTakerName),
"First Count Qty / 初盤數量": d?.pickerFirstQty ?? "",
"Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "",
"Approver Count Qty / 覆盤數量": d?.approverQty ?? "",
"Record Status / 紀錄狀態": orDash(d?.recordStatus),
"Date/Time / 時間": orDash(e.timestamp),
};
});
return ensureRows(rows, stockTakeEmpty());
};

const productionEmpty = (): Record<string, unknown> => ({
"Section / 區塊": "",
"Job Order / 工單": "",
"Doc No. / 單號": "",
"Item Code / 貨品編號": "",
"Lot / 批號": "",
"Item Name / 品名": "",
"Step / 步驟": "",
"Assigned Step / 對應製程": "",
"Qty / 數量": "",
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": "",
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": "",
});

const buildProductionSheet = (
data: ItemLotTraceResponse,
compiledGraph: CompiledTraceGraph,
tr: TraceLabelTranslator,
): Record<string, unknown>[] => {
const rows: Record<string, unknown>[] = [];
const presentation = buildTracePresentationRows(compiledGraph.nodes, data);

const upstreamSource = data.joPrelude?.materialInputs?.length
? data.joPrelude.materialInputs.map((m) => ({
jobOrderCode: m.jobOrderCode,
materialItemCode: m.materialItemCode,
materialLotNo: m.materialLotNo,
materialQty: m.materialQty,
materialUom: m.materialUom,
assignedStepName: m.assignedStepName,
pickOrderCode: m.pickOrderCode,
pickedAt: m.pickedAt,
materialItemName: m.materialItemName,
}))
: data.bomTrace.upstream.map((u) => ({
jobOrderCode: u.jobOrderCode,
materialItemCode: u.materialItemCode,
materialLotNo: u.materialLotNo,
materialQty: u.materialQty,
materialUom: "",
assignedStepName: "",
pickOrderCode: "",
pickedAt: null as string | null,
materialItemName: "",
}));

upstreamSource.forEach((m) => {
rows.push({
"Section / 區塊": "BOM Upstream / BOM 上游",
"Job Order / 工單": orDash(m.jobOrderCode),
"Doc No. / 單號": orDash(m.pickOrderCode),
"Item Code / 貨品編號": orDash(m.materialItemCode),
"Lot / 批號": orDash(m.materialLotNo),
"Item Name / 品名": orDash(m.materialItemName),
"Step / 步驟": "",
"Assigned Step / 對應製程": orDash(m.assignedStepName),
"Qty / 數量": m.materialQty,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": orDash(m.materialUom),
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": orDash(m.pickedAt),
});
});

data.bomTrace.downstream.forEach((d) => {
rows.push({
"Section / 區塊": "BOM Downstream / BOM 下游",
"Job Order / 工單": orDash(d.jobOrderCode),
"Doc No. / 單號": "",
"Item Code / 貨品編號": orDash(d.finishedItemCode),
"Lot / 批號": orDash(d.finishedLotNo),
"Item Name / 品名": "",
"Step / 步驟": "",
"Assigned Step / 對應製程": "",
"Qty / 數量": d.fgQty,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": orDash(d.fgUom),
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": "",
});
});

data.bomTrace.bomRecipe.forEach((r) => {
rows.push({
"Section / 區塊": "BOM Recipe / BOM 配方",
"Job Order / 工單": "",
"Doc No. / 單號": "",
"Item Code / 貨品編號": orDash(r.materialItemCode),
"Lot / 批號": "",
"Item Name / 品名": orDash(r.materialItemName),
"Step / 步驟": "",
"Assigned Step / 對應製程": orDash(r.assignedStepName),
"Qty / 數量": r.qtyPerUnit,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": orDash(r.uom),
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": "",
});
});

presentation.joPicks
.slice()
.sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? ""))
.forEach((p) => {
rows.push({
"Section / 區塊": "JO Pick / 工單提料",
"Job Order / 工單": "",
"Doc No. / 單號": orDash(p.pickOrderCode),
"Item Code / 貨品編號": orDash(p.materialItemCode),
"Lot / 批號": orDash(p.materialLotNo),
"Item Name / 品名": "",
"Step / 步驟": "",
"Assigned Step / 對應製程": orDash(p.assignedStepName),
"Qty / 數量": p.qty,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": orDash(p.uom),
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": orDash(p.pickedAt),
});
});

const productionRows =
data.productionSteps.length > 0
? data.productionSteps
.slice()
.sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
.map((s) => ({
"Section / 區塊": "Production Step / 生產步驟",
"Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
"Doc No. / 單號": "",
"Item Code / 貨品編號": orDash(data.lot.itemCode),
"Lot / 批號": orDash(data.lot.lotNo),
"Item Name / 品名": orDash(data.lot.itemName),
"Step / 步驟": orDash(s.stepName),
"Assigned Step / 對應製程": orDash(s.description),
"Qty / 數量": s.outputQty,
"Scrap Qty / 損耗數量": s.scrapQty,
"Defect Qty / 不良數量": s.defectQty,
"UOM / 單位": orDash(data.lot.uom),
"Operator / 操作員": orDash(s.operatorName),
"Equipment / 設備":
[s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") ||
"—",
"Status / 狀態": tr.productionStatus(s.status),
"Date/Time / 時間": orDash(s.endTime || s.startTime),
}))
: presentation.production
.slice()
.sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
.map((s) => ({
"Section / 區塊": "Production Step / 生產步驟",
"Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
"Doc No. / 單號": "",
"Item Code / 貨品編號": orDash(data.lot.itemCode),
"Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo),
"Item Name / 品名": orDash(data.lot.itemName),
"Step / 步驟": orDash(s.stepName),
"Assigned Step / 對應製程": orDash(s.description),
"Qty / 數量": s.outputQty,
"Scrap Qty / 損耗數量": s.scrapQty,
"Defect Qty / 不良數量": s.defectQty,
"UOM / 單位": orDash(data.lot.uom),
"Operator / 操作員": orDash(s.operatorName),
"Equipment / 設備": orDash(s.equipmentName || s.equipmentCode),
"Status / 狀態": orDash(s.status),
"Date/Time / 時間": orDash(s.endTime || s.startTime),
}));

rows.push(...productionRows);

data.byproductLots.forEach((b) => {
rows.push({
"Section / 區塊": "Byproduct / 副產品",
"Job Order / 工單": orDash(b.jobOrderCode),
"Doc No. / 單號": "",
"Item Code / 貨品編號": orDash(b.itemCode),
"Lot / 批號": orDash(b.lotNo),
"Item Name / 品名": orDash(b.itemName),
"Step / 步驟": orDash(b.processStepName),
"Assigned Step / 對應製程": "",
"Qty / 數量": b.qty,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": orDash(b.uom),
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": "",
"Date/Time / 時間": orDash(b.producedAt),
});
});

data.lotRelations.forEach((r) => {
rows.push({
"Section / 區塊": "Lot Relation / 拆批關聯",
"Job Order / 工單": "",
"Doc No. / 單號": orDash(r.relationType),
"Item Code / 貨品編號": orDash(r.itemCode),
"Lot / 批號": orDash(r.lotNo),
"Item Name / 品名": orDash(r.itemName),
"Step / 步驟": "",
"Assigned Step / 對應製程": "",
"Qty / 數量": r.qty,
"Scrap Qty / 損耗數量": "",
"Defect Qty / 不良數量": "",
"UOM / 單位": "",
"Operator / 操作員": "",
"Equipment / 設備": "",
"Status / 狀態": orDash(r.productLotNo),
"Date/Time / 時間": orDash(r.timestamp),
});
});

return ensureRows(rows, productionEmpty());
};

/** Build the 8 classified sheets without triggering download (for tests). */
export const buildItemLotTraceSheets = (
data: ItemLotTraceResponse,
compiledGraph: CompiledTraceGraph,
t: TFunction,
exportLabels: ItemLotTraceExportLabels,
exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
): MultiSheetSpec[] => {
const tr = createTraceLabelTranslator(t);
return [
{
name: SHEET.summary,
rows: buildSummarySheet(data, exportLabels, exportAt),
},
{ name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
{ name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
{ name: SHEET.qc, rows: buildQcSheet(data, tr) },
{ name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
{ name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
{ name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
{
name: SHEET.production,
rows: buildProductionSheet(data, compiledGraph, tr),
},
];
};

export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET);

/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
export const exportItemLotTraceXlsx = (
data: ItemLotTraceResponse,
compiledGraph: CompiledTraceGraph,
t: TFunction,
): void => {
const exportLabels: ItemLotTraceExportLabels = {
bomDirectionFg: t("bomDirectionFG"),
bomDirectionMaterial: t("bomDirectionMaterial"),
bomDirectionUnknown: t("bomDirectionUnknown"),
};
const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels);
exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data));
};

+ 5
- 0
src/components/ItemTracing/index.ts Ver arquivo

@@ -0,0 +1,5 @@
export { default } from "./ItemTracing";
export { default as ItemTracingScanBar } from "./ItemTracingScanBar";
export { default as ItemTracingSummary } from "./ItemTracingSummary";
export { default as ItemTracingFlowGraph } from "./ItemTracingFlowGraph";
export { default as ItemTracingSections } from "./ItemTracingSections";

+ 113
- 0
src/components/ItemTracing/itemTracingTableFilters.tsx Ver arquivo

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

import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { type ReactNode } from "react";

export type FilterableColumnDef<T> = {
key: string;
label: string;
align?: "left" | "right" | "center";
value: (row: T) => string | number | null | undefined;
cell?: (row: T) => ReactNode;
/** @deprecated Unused — filters removed. */
filterable?: boolean;
};

type FilterableDataTableProps<T> = {
rows: T[];
columns: FilterableColumnDef<T>[];
getRowKey: (row: T, index: number) => string;
emptyLabel: string;
size?: "small" | "medium";
collapseAfter?: number;
showAll?: boolean;
onToggleShowAll?: () => void;
showAllLabel?: string | ((count: number) => string);
collapseLabel?: string | ((count: number) => string);
};

const resolveCountLabel = (
label: string | ((count: number) => string) | undefined,
count: number,
fallback: string,
) => (typeof label === "function" ? label(count) : (label ?? fallback));

/** Plain data table (column filters removed). */
export function FilterableDataTable<T>({
rows,
columns,
getRowKey,
emptyLabel,
size = "small",
collapseAfter,
showAll = false,
onToggleShowAll,
showAllLabel,
collapseLabel,
}: FilterableDataTableProps<T>) {
const collapsed =
collapseAfter != null && rows.length > collapseAfter && !showAll;
const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows;

return (
<>
<Table size={size}>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
sx={{ fontWeight: 700, whiteSpace: "nowrap" }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{visibleRows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>
<Typography color="text.secondary" variant="body2">
{emptyLabel}
</Typography>
</TableCell>
</TableRow>
) : (
visibleRows.map((row, index) => (
<TableRow key={getRowKey(row, index)}>
{columns.map((col) => (
<TableCell key={col.key} align={col.align}>
{col.cell ? col.cell(row) : (col.value(row) ?? "—")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
{collapseAfter != null &&
rows.length > collapseAfter &&
onToggleShowAll && (
<Typography
variant="caption"
color="primary"
sx={{ cursor: "pointer", mt: 0.5, display: "inline-block" }}
onClick={onToggleShowAll}
>
{showAll
? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`)
: resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)}
</Typography>
)}
</>
);
}

+ 316
- 0
src/components/ItemTracing/mergeLocationScopedData.ts Ver arquivo

@@ -0,0 +1,316 @@
import type {
ItemLotTraceLocationBlock,
ItemLotTraceResponse,
} from "@/app/api/itemTracing";
import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";

export type ScopedRow<T> = T & {
scopeWarehouseCode: string;
inventoryLotId: number;
};

export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string =>
data.warehouseLines
.map((w) => w.warehouseCode)
.filter(Boolean)
.join(" / ") || "—";

const sortByTimestampDesc = <T extends { timestamp?: string | null }>(
rows: T[],
): T[] =>
[...rows].sort((a, b) =>
(b.timestamp ?? "").localeCompare(a.timestamp ?? ""),
);

const sortByReceiptDateDesc = <T extends { receiptDate?: string | null }>(
rows: T[],
): T[] =>
[...rows].sort((a, b) =>
(b.receiptDate ?? "").localeCompare(a.receiptDate ?? ""),
);

export const mergeScopedOrigins = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.origins.map((o) => ({
...o,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.origins.map((o) => ({
...o,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
}));
});
return sortByReceiptDateDesc([...primary, ...fromBlocks]);
};

export const mergeScopedQcResults = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.qcResults.map((q, i) => ({
...q,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-${q.stockInLineId}-${i}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.qcResults.map((q, i) => ({
...q,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-${q.stockInLineId}-${i}`,
}));
});
return [...primary, ...fromBlocks].sort((a, b) =>
(b.created ?? "").localeCompare(a.created ?? ""),
);
};

export const mergeScopedOutboundUsage = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.outboundUsage.map((u) => ({
...u,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.outboundUsage.map((u) => ({
...u,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedStockTakeEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.stockTakeEvents.map((e, i) => ({
...e,
scopeWarehouseCode: e.warehouseCode?.trim() || primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-${e.stockTakeCode}-${i}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.stockTakeEvents.map((e, i) => ({
...e,
scopeWarehouseCode: e.warehouseCode?.trim() || wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-${e.stockTakeCode}-${i}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedAdjustments = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.adjustments.map((a, i) => ({
...a,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-${a.refCode}-${i}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.adjustments.map((a, i) => ({
...a,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-${a.refCode}-${i}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedTransfers = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.transfers.map((tr, i) => ({
...tr,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-${tr.transferCode}-${i}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.transfers.map((tr, i) => ({
...tr,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-${tr.transferCode}-${i}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedMovements = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.movements.map((m, i) => ({
...m,
scopeWarehouseCode: m.warehouseCode?.trim() || primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-mov-${i}-${m.refCode}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.movements.map((m, i) => ({
...m,
scopeWarehouseCode: m.warehouseCode?.trim() || wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-mov-${i}-${m.refCode}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedPutawayEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.putawayEvents.map((p, i) => ({
...p,
scopeWarehouseCode: p.warehouseCode?.trim() || primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-putaway-${i}-${p.refCode}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.putawayEvents.map((p, i) => ({
...p,
scopeWarehouseCode: p.warehouseCode?.trim() || wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-putaway-${i}-${p.refCode}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedPurchaseEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = (data.purchaseEvents ?? []).map((p, i) => ({
...p,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-po-${i}-${p.purchaseOrderLineId}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return (block.purchaseEvents ?? []).map((p, i) => ({
...p,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-po-${i}-${p.purchaseOrderLineId}`,
}));
});
return [...primary, ...fromBlocks].sort((a, b) =>
(b.orderDate ?? "").localeCompare(a.orderDate ?? ""),
);
};

export const mergeScopedDoDeliveries = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.doDeliveries.map((d, i) => ({
...d,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-do-${i}-${d.stockOutLineId}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.doDeliveries.map((d, i) => ({
...d,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-do-${i}-${d.stockOutLineId}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedFailEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.failEvents.map((f, i) => ({
...f,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-fail-${i}-${f.failId}`,
timestamp: f.recordDate,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.failEvents.map((f, i) => ({
...f,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-fail-${i}-${f.failId}`,
timestamp: f.recordDate,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedReturnEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.returnEvents.map((r, i) => ({
...r,
scopeWarehouseCode: r.warehouseCode?.trim() || primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-return-${i}-${r.stockOutLineId}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.returnEvents.map((r, i) => ({
...r,
scopeWarehouseCode: r.warehouseCode?.trim() || wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-return-${i}-${r.stockOutLineId}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedOpenMovements = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = data.openMovements.map((o, i) => ({
...o,
scopeWarehouseCode: o.warehouseCode?.trim() || primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-open-${i}-${o.stockInLineId}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return block.openMovements.map((o, i) => ({
...o,
scopeWarehouseCode: o.warehouseCode?.trim() || wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-open-${i}-${o.stockInLineId}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const mergeScopedReplenishmentEvents = (data: ItemLotTraceResponse) => {
const primaryWh = primaryWarehouseLabel(data);
const primary = (data.replenishmentEvents ?? []).map((r, i) => ({
...r,
scopeWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
rowKey: `primary-replenish-${i}-${r.replenishmentId}`,
}));
const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => {
const wh = blockWarehouseCode(block);
return (block.replenishmentEvents ?? []).map((r, i) => ({
...r,
scopeWarehouseCode: wh,
inventoryLotId: block.inventoryLotId,
rowKey: `loc-${block.inventoryLotId}-replenish-${i}-${r.replenishmentId}`,
}));
});
return sortByTimestampDesc([...primary, ...fromBlocks]);
};

export const hasMultipleLocations = (data: ItemLotTraceResponse): boolean =>
(data.locationBlocks?.length ?? 0) > 0 ||
(data.alternateLocations?.length ?? 0) > 0;

+ 274
- 0
src/components/ItemTracing/traceDoGroupLayout.ts Ver arquivo

@@ -0,0 +1,274 @@
import {
DO_GROUP_CHILD_WIDTH,
DO_GROUP_HEADER,
DO_GROUP_INNER_COLS_MAX,
DO_GROUP_MIN_SIZE,
DO_GROUP_PAD,
PICK_GROUP_MIN_SIZE,
NODE_GAP,
NODE_HEIGHT,
NODE_WIDTH,
} from "./traceFlowConstants";
import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout";
import { TraceGraphNode } from "./buildTraceGraphNodes";

export type DoGroupChildPlacement = { x: number; y: number; compact: boolean };

/** Fewer columns for large groups — wider cards, no horizontal cramming. */
const preferredDoGroupCols = (memberCount: number): number => {
if (memberCount <= 1) return 1;
if (memberCount >= 15) return 2;
if (memberCount >= 6) return 3;
return 2;
};

const sumDoGroupQty = (
children: TraceGraphLayoutNode[],
): { totalQty: number; uom?: string } | null => {
let totalQty = 0;
let hasQty = false;
let uom: string | undefined;
children.forEach((child) => {
const qty = child.qty;
if (qty != null && Number.isFinite(Number(qty))) {
totalQty += Number(qty);
hasQty = true;
}
if (!uom && child.uom?.trim()) uom = child.uom.trim();
});
return hasQty ? { totalQty, uom } : null;
};

export const computeDoGroupBoxLayout = (
memberCount: number,
): { width: number; height: number; cols: number } => {
const childW = DO_GROUP_CHILD_WIDTH;
const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount));
const rows = Math.ceil(memberCount / cols);
const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP;
const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP;
return {
width: gridW + DO_GROUP_PAD * 2,
height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2,
cols,
};
};

export const layoutDoGroupChildPlacements = (
children: TraceGraphLayoutNode[],
): Map<string, DoGroupChildPlacement> => {
const sorted = [...children].sort(sortNodesInPhase);
const { cols } = computeDoGroupBoxLayout(sorted.length);
const childW = DO_GROUP_CHILD_WIDTH;
const result = new Map<string, DoGroupChildPlacement>();

sorted.forEach((child, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
result.set(child.id, {
x: DO_GROUP_PAD + col * (childW + NODE_GAP),
y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP),
compact: false,
});
});

return result;
};

export const applyDoOutboundGrouping = (
nodes: TraceGraphLayoutNode[],
groupTitle: (count: number) => string,
minSize = DO_GROUP_MIN_SIZE,
): TraceGraphLayoutNode[] => {
const doByColumnAndWarehouse = new Map<string, TraceGraphLayoutNode[]>();

nodes.forEach((node) => {
if (node.kind !== "DO_OUT" || node.doGroupId) return;
const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__";
const key = `${node.column}::${whKey}`;
const list = doByColumnAndWarehouse.get(key) ?? [];
list.push(node);
doByColumnAndWarehouse.set(key, list);
});

const groupNodes: TraceGraphLayoutNode[] = [];
const childGroupIds = new Map<string, string>();

doByColumnAndWarehouse.forEach((columnNodes, key) => {
if (columnNodes.length < minSize) return;

const column = columnNodes[0]!.column;
const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined;
const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_");
const groupId = `do-group-col-${column}-${whSlug}`;
const sorted = [...columnNodes].sort(sortNodesInPhase);
const { width, height } = computeDoGroupBoxLayout(sorted.length);
const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey);
const qtySummary = sumDoGroupQty(sorted);

sorted.forEach((child) => childGroupIds.set(child.id, groupId));

const groupNode: TraceGraphLayoutNode = {
id: groupId,
kind: "DO_GROUP",
timestamp: sorted[0]?.timestamp ?? null,
sortKey: minSort,
title: groupTitle(sorted.length),
subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "",
categoryLabel: sorted[0]?.categoryLabel,
warehouseCode,
details: [],
phase: sorted[0]!.phase,
column,
dayKey: sorted[0]!.dayKey,
laneIndex: sorted[0]!.laneIndex,
sequenceIndex: sorted[0]!.sequenceIndex,
branchIndex: 0,
branchSize: 1,
dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex,
groupBoxWidth: width,
groupBoxHeight: height,
groupMemberCount: sorted.length,
groupTotalQty: qtySummary?.totalQty,
groupUom: qtySummary?.uom,
};
groupNodes.push(groupNode);
});

if (!groupNodes.length) return nodes;

return [
...nodes.map((node) => {
const groupId = childGroupIds.get(node.id);
if (!groupId) return node;
return { ...node, doGroupId: groupId };
}),
...groupNodes,
].sort((a, b) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
return a.id.localeCompare(b.id);
});
};

export const applyMaterialPickGrouping = (
nodes: TraceGraphLayoutNode[],
groupTitle: (pickOrderCode: string, count: number) => string,
minSize = PICK_GROUP_MIN_SIZE,
): TraceGraphLayoutNode[] => {
const picksByKey = new Map<string, TraceGraphLayoutNode[]>();

nodes.forEach((node) => {
if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return;
const pickCode = node.refCode?.trim();
if (!pickCode) return;
const key = `${node.column}::${pickCode}`;
const list = picksByKey.get(key) ?? [];
list.push(node);
picksByKey.set(key, list);
});

const groupNodes: TraceGraphLayoutNode[] = [];
const childGroupIds = new Map<string, string>();

picksByKey.forEach((columnNodes, key) => {
if (columnNodes.length < minSize) return;

const pickCode = key.split("::").slice(1).join("::");
const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
const sorted = [...columnNodes].sort(sortNodesInPhase);
const { width, height } = computeDoGroupBoxLayout(sorted.length);
const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey);

sorted.forEach((child) => childGroupIds.set(child.id, groupId));

const groupNode: TraceGraphLayoutNode = {
id: groupId,
kind: "PICK_GROUP",
timestamp: sorted[0]?.timestamp ?? null,
sortKey: minSort,
title: groupTitle(pickCode, sorted.length),
subtitle: sorted[0]?.dayKey ?? "",
refCode: pickCode,
refId: sorted[0]?.refId,
docLinkKind: "pick",
consoCode: sorted[0]?.consoCode,
jobOrderCode: sorted[0]?.jobOrderCode,
categoryLabel: sorted[0]?.categoryLabel,
details: [],
phase: sorted[0]!.phase,
column: sorted[0]!.column,
dayKey: sorted[0]!.dayKey,
laneIndex: sorted[0]!.laneIndex,
sequenceIndex: sorted[0]!.sequenceIndex,
branchIndex: 0,
branchSize: 1,
dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex,
groupBoxWidth: width,
groupBoxHeight: height,
groupMemberCount: sorted.length,
};
groupNodes.push(groupNode);
});

if (!groupNodes.length) return nodes;

return [
...nodes.map((node) => {
const groupId = childGroupIds.get(node.id);
if (!groupId) return node;
return { ...node, doGroupId: groupId };
}),
...groupNodes,
].sort((a, b) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
return a.id.localeCompare(b.id);
});
};

export const applyFlowNodeGrouping = (
nodes: TraceGraphLayoutNode[],
labels: {
flowDoGroupTitle: (count: number) => string;
flowPickGroupTitle: (pickOrderCode: string, count: number) => string;
},
): TraceGraphLayoutNode[] =>
applyMaterialPickGrouping(
applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle),
labels.flowPickGroupTitle,
);

export const regroupLayoutCells = (
nodes: TraceGraphLayoutNode[],
): TraceGraphLayoutNode[][] => {
const visible = nodes.filter((n) => !isDoGroupChild(n));
const groups = new Map<string, TraceGraphLayoutNode[]>();

visible.forEach((node) => {
const key = `${node.column}:${node.laneIndex}`;
const list = groups.get(key) ?? [];
list.push(node);
groups.set(key, list);
});

groups.forEach((list) => {
list.sort(sortNodesInPhase);
const size = list.length;
list.forEach((node, idx) => {
node.branchIndex = idx;
node.branchSize = size;
});
});

return Array.from(groups.values()).sort((a, b) => {
const na = a[0]!;
const nb = b[0]!;
if (na.column !== nb.column) return na.column - nb.column;
return na.laneIndex - nb.laneIndex;
});
};

export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean =>
node.kind === "DO_GROUP" || node.kind === "PICK_GROUP";

export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean =>
Boolean(node.doGroupId?.trim());

+ 155
- 0
src/components/ItemTracing/traceDocLinkUtils.ts Ver arquivo

@@ -0,0 +1,155 @@
import dayjs from "dayjs";
import {
ItemLotTraceJoPrelude,
ItemLotTraceResponse,
} from "@/app/api/itemTracing";

export const isWorkbenchTicketNo = (ticketNo?: string | null): boolean =>
(ticketNo ?? "").trim().toUpperCase().startsWith("TI-");

/** Normalize any date/datetime string to YYYY-MM-DD for API / URL query params (LocalDate). */
export const normalizeTargetDateForLink = (
value?: string | null,
): string | undefined => {
if (value == null) return undefined;
const raw = String(value).trim();
if (!raw) return undefined;
// Prefer extracting YYYY-MM-DD prefix — dayjs/Date.parse is unreliable for
// "YYYY-MM-DD HH:mm:ss" in some browsers (e.g. Safari).
const match = raw.match(/(\d{4}-\d{2}-\d{2})/);
if (match) return match[1];
const d = dayjs(raw);
return d.isValid() ? d.format("YYYY-MM-DD") : undefined;
};

export const targetDateFromTraceTimestamp = (
timestamp?: string | null,
): string | undefined => normalizeTargetDateForLink(timestamp);

export type DoOutboundDocLink = {
kind: "workbench" | "pick" | undefined;
ticketNo?: string;
targetDate?: string;
/** Visible link label (DO code preferred on DO_OUT cards). */
displayCode: string;
pickOrderId?: number | null;
consoCode?: string;
};

export type JoPickDocLink = {
kind: "jodetail";
pickOrderCode: string;
targetDate?: string;
displayCode: string;
pickOrderId?: number | null;
};

const walkJoPreludePickOrders = (
prelude: ItemLotTraceJoPrelude,
map: Map<string, string>,
): void => {
prelude.pickOrders.forEach((po) => {
const code = po.pickOrderCode?.trim();
const date = normalizeTargetDateForLink(po.targetDate);
if (code && date) map.set(code, date);
});
prelude.materialInputs.forEach((m) => {
if (m.nestedJoPrelude) walkJoPreludePickOrders(m.nestedJoPrelude, map);
});
};

export const buildPickOrderTargetDateMapFromPrelude = (
prelude: ItemLotTraceJoPrelude,
): Map<string, string> => {
const map = new Map<string, string>();
walkJoPreludePickOrders(prelude, map);
return map;
};

export const buildPickOrderTargetDateMap = (
data: ItemLotTraceResponse,
): Map<string, string> =>
data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map();

export const resolvePickOrderTargetDate = (
map: Map<string, string>,
pickOrderCode?: string | null,
fallbackTimestamp?: string | null,
): string | undefined => {
const code = pickOrderCode?.trim();
if (code) {
const fromPrelude = normalizeTargetDateForLink(map.get(code));
if (fromPrelude) return fromPrelude;
}
return targetDateFromTraceTimestamp(fallbackTimestamp);
};

export const resolveJoPickDocLink = (input: {
pickOrderCode?: string;
pickOrderId?: number | null;
timestamp?: string | null;
targetDate?: string | null;
}): JoPickDocLink | null => {
const pickCode = input.pickOrderCode?.trim() || "";
if (!pickCode) return null;
return {
kind: "jodetail",
pickOrderCode: pickCode,
targetDate:
normalizeTargetDateForLink(input.targetDate) ||
targetDateFromTraceTimestamp(input.timestamp),
displayCode: pickCode,
pickOrderId: input.pickOrderId,
};
};

export const resolveDoOutboundDocLink = (input: {
pickOrderCode?: string;
pickOrderId?: number | null;
deliveryOrderCode?: string;
ticketNo?: string;
outboundTicketNo?: string;
consoCode?: string;
timestamp?: string | null;
/** Workbench path only when linked to delivery_order_pick_order. */
deliveryOrderPickOrderId?: number | null;
}): DoOutboundDocLink => {
const ticket = (input.ticketNo || input.outboundTicketNo || "").trim();
const conso = (input.consoCode || "").trim();
const hasWorkbenchLink =
input.deliveryOrderPickOrderId != null &&
Number.isFinite(Number(input.deliveryOrderPickOrderId));
const workbenchTicket = hasWorkbenchLink
? isWorkbenchTicketNo(ticket)
? ticket
: isWorkbenchTicketNo(conso)
? conso
: undefined
: undefined;
const targetDate = targetDateFromTraceTimestamp(input.timestamp);
const pickCode = input.pickOrderCode?.trim() || "";
const doCode = input.deliveryOrderCode?.trim() || "";

if (workbenchTicket) {
return {
kind: "workbench",
ticketNo: workbenchTicket,
targetDate,
displayCode: doCode || pickCode || workbenchTicket,
pickOrderId: input.pickOrderId,
consoCode: conso || pickCode,
};
}
// Prefer 送貨單號 on the card; /pickOrder/detail is retired so no pick-only link.
// Legacy GoodPick TI-* without deliveryOrderPickOrderId must not deep-link to workbench.
if (doCode || pickCode) {
return {
kind: undefined,
displayCode: doCode || pickCode,
pickOrderId: input.pickOrderId,
consoCode: conso || pickCode,
targetDate,
};
}
return { kind: undefined, displayCode: "—" };
};

+ 38
- 0
src/components/ItemTracing/traceFlowConstants.ts Ver arquivo

@@ -0,0 +1,38 @@
export const LANE_MIN_HEIGHT = 150;
export const LANE_GAP = 32;
/** Minimum width of one phase slot within a calendar day column. */
export const COLUMN_WIDTH_MIN = 320;
/** @deprecated use COLUMN_WIDTH_MIN — kept for imports that expect COLUMN_WIDTH */
export const COLUMN_WIDTH = COLUMN_WIDTH_MIN;
export const PHASE_LABEL_WIDTH = 112;
export const HEADER_HEIGHT = 40;
/** Trace event card width — sized for PO codes and supplier names. */
export const NODE_WIDTH = 268;
export const NODE_WIDTH_BRANCH = 220;
/** Fixed trace event card height — layout spacing must match rendered Paper height.
* Sized for densest cards (MATERIAL_PICK / JO_OUT with item, statuses, timestamp). */
export const NODE_HEIGHT = 280;
export const NODE_GAP = 16;
/** React Flow canvas height in the life-cycle graph panel. */
export const VIEWPORT_HEIGHT = 720;
/** Initial fitView will not zoom out below this (keeps nodes readable). */
export const FIT_VIEW_MIN_ZOOM = 0.55;
export const FIT_VIEW_MAX_ZOOM = 1.35;

/**
* Min DO_OUT nodes in one day column before collapsing into a group box.
* Higher than pick groups: individual DO tickets stay readable until the column
* gets dense (≥3 same day + warehouse).
*/
export const DO_GROUP_MIN_SIZE = 3;
/**
* Min MATERIAL_PICK lines sharing a pick order before collapsing into a group box.
* Intentional asymmetry vs DO_GROUP_MIN_SIZE: warehouse users think in pick-order
* batches, so even a single material line sits inside its pick-order box.
*/
export const PICK_GROUP_MIN_SIZE = 1;
export const DO_GROUP_PAD = 12;
export const DO_GROUP_HEADER = 32;
export const DO_GROUP_INNER_COLS_MAX = 3;
/** Card width used inside a DO group grid (full-size, not branch compact). */
export const DO_GROUP_CHILD_WIDTH = NODE_WIDTH;

+ 395
- 0
src/components/ItemTracing/traceFlowEdgeLayout.ts Ver arquivo

@@ -0,0 +1,395 @@
export type TraceFlowPair = { fromId: string; toId: string };

export type RoutedTraceFlowEdge = TraceFlowPair & {
sourceHandle: string;
targetHandle: string;
offset: number;
pathMode?: "smooth" | "corridor";
corridorY?: number;
/** Added to React Flow sourceX for the first horizontal stub. */
corridorExitOffset?: number;
/** Added to React Flow targetX for the vertical drop into the target. */
corridorEntryOffset?: number;
/**
* Added to React Flow sourceX for the late branch point on the corridor.
* Shared-trunk fans travel together until this X, then fork to each target.
*/
corridorBranchOffset?: number;
};

export type TraceFlowNodeRect = {
x: number;
y: number;
width: number;
height: number;
laneIndex: number;
};

export type TraceFlowLaneLayout = {
laneTops: number[];
laneHeights: number[];
laneGap: number;
};

const OFFSET_STEP = 18;
const CORRIDOR_STEP = 14;
const HORIZONTAL_STUB = 14;
const MIN_HORIZONTAL_SPAN = 24;

export const TRACE_FLOW_HANDLE_IN = "in";
export const TRACE_FLOW_HANDLE_OUT = "out";

const stableSortPairs = (list: TraceFlowPair[]): TraceFlowPair[] =>
[...list].sort((a, b) => {
const keyA = `${a.fromId}->${a.toId}`;
const keyB = `${b.fromId}->${b.toId}`;
return keyA.localeCompare(keyB);
});

const edgeKey = (fromId: string, toId: string) => `${fromId}->${toId}`;

const targetColumnKey = (rect: TraceFlowNodeRect) =>
`${rect.laneIndex}:${Math.round(rect.x)}`;

const sourceColumnKey = (rect: TraceFlowNodeRect) =>
`${rect.laneIndex}:${Math.round(rect.x)}`;

type CorridorMeta = {
corridorY: number;
corridorExitOffset: number;
corridorEntryOffset: number;
corridorBranchOffset?: number;
};

/** Spread multiple edges from the same node via handles + smoothstep offset. */
export const assignTraceFlowEdgeRouting = (
pairs: TraceFlowPair[],
): {
routed: RoutedTraceFlowEdge[];
incomingCount: Map<string, number>;
outgoingCount: Map<string, number>;
} => {
const bySource = new Map<string, TraceFlowPair[]>();
const byTarget = new Map<string, TraceFlowPair[]>();

pairs.forEach((p) => {
const outList = bySource.get(p.fromId) ?? [];
outList.push(p);
bySource.set(p.fromId, outList);

const inList = byTarget.get(p.toId) ?? [];
inList.push(p);
byTarget.set(p.toId, inList);
});

bySource.forEach((list, id) => bySource.set(id, stableSortPairs(list)));
byTarget.forEach((list, id) => byTarget.set(id, stableSortPairs(list)));

const incomingCount = new Map<string, number>();
const outgoingCount = new Map<string, number>();
byTarget.forEach((list, id) => incomingCount.set(id, list.length));
// 1→N fans use a single source handle (shared trunk); report 1 outbound slot.
bySource.forEach((list, id) => outgoingCount.set(id, list.length > 1 ? 1 : list.length));

const routed: RoutedTraceFlowEdge[] = pairs.map((p) => {
const outList = bySource.get(p.fromId) ?? [p];
const inList = byTarget.get(p.toId) ?? [p];
const sourceIndex = outList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId);
const targetIndex = inList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId);

// 1→N fans leave from one handle so the stroke starts as a single trunk.
const sourceHandle =
outList.length > 1
? `${TRACE_FLOW_HANDLE_OUT}-0`
: `${TRACE_FLOW_HANDLE_OUT}-${Math.max(0, sourceIndex)}`;
const targetHandle = `${TRACE_FLOW_HANDLE_IN}-${Math.max(0, targetIndex)}`;

let offset = 0;
if (outList.length > 1) {
offset = 0;
} else if (inList.length > 1) {
offset = (targetIndex - (inList.length - 1) / 2) * OFFSET_STEP;
}

return { ...p, sourceHandle, targetHandle, offset, pathMode: "smooth" };
});

return { routed, incomingCount, outgoingCount };
};

const baseCorridorY = (
src: TraceFlowNodeRect,
srcLane: number,
tgtLane: number,
lanes: TraceFlowLaneLayout,
): number => {
if (srcLane !== tgtLane) {
const srcLaneBottom =
(lanes.laneTops[srcLane] ?? src.y) + (lanes.laneHeights[srcLane] ?? 0);
return srcLaneBottom + lanes.laneGap * 0.42;
}
return src.y + src.height + 12;
};

/**
* Route long horizontal spans through the lane gutter so edges do not cut across
* unrelated nodes in the same swimlane.
* 1→N source fans share one corridor trunk and only branch near targets.
*/
export const enrichTraceFlowCorridorRouting = (
routed: RoutedTraceFlowEdge[],
rects: Map<string, TraceFlowNodeRect>,
lanes: TraceFlowLaneLayout,
): RoutedTraceFlowEdge[] => {
const outDegree = new Map<string, number>();
routed.forEach((edge) => {
outDegree.set(edge.fromId, (outDegree.get(edge.fromId) ?? 0) + 1);
});

const corridorCandidates: RoutedTraceFlowEdge[] = [];

routed.forEach((edge) => {
const src = rects.get(edge.fromId);
const tgt = rects.get(edge.toId);
if (!src || !tgt) return;

const srcRight = src.x + src.width;
const crossLane = src.laneIndex !== tgt.laneIndex;
const longSpan = tgt.x > srcRight + MIN_HORIZONTAL_SPAN;
const sourceFan = (outDegree.get(edge.fromId) ?? 0) > 1;
if (crossLane || longSpan || sourceFan) {
corridorCandidates.push(edge);
}
});

const corridorMetaByKey = new Map<string, CorridorMeta>();
const groups = new Map<string, RoutedTraceFlowEdge[]>();

corridorCandidates.forEach((edge) => {
const src = rects.get(edge.fromId);
const tgt = rects.get(edge.toId);
if (!src || !tgt) return;
const groupKey = `${src.laneIndex}->${tgt.laneIndex}`;
const list = groups.get(groupKey) ?? [];
list.push(edge);
groups.set(groupKey, list);
});

groups.forEach((edges, groupKey) => {
const [srcLaneStr, tgtLaneStr] = groupKey.split("->");
const srcLane = Number(srcLaneStr);
const tgtLane = Number(tgtLaneStr);

const byFrom = new Map<string, RoutedTraceFlowEdge[]>();
edges.forEach((edge) => {
const list = byFrom.get(edge.fromId) ?? [];
list.push(edge);
byFrom.set(edge.fromId, list);
});

// Distinct sources in this lane-pair get separated corridor Y bands.
const sourceIds = Array.from(byFrom.keys()).sort((a, b) => {
const ax = rects.get(a)?.x ?? 0;
const bx = rects.get(b)?.x ?? 0;
if (ax !== bx) return ax - bx;
const ay = rects.get(a)?.y ?? 0;
const by = rects.get(b)?.y ?? 0;
return ay - by || a.localeCompare(b);
});

sourceIds.forEach((fromId, sourceIndex) => {
const fan = [...(byFrom.get(fromId) ?? [])].sort((a, b) => {
const aty = rects.get(a.toId)?.y ?? 0;
const bty = rects.get(b.toId)?.y ?? 0;
if (aty !== bty) return aty - bty;
const atx = rects.get(a.toId)?.x ?? 0;
const btx = rects.get(b.toId)?.x ?? 0;
return (
atx - btx ||
edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId))
);
});
const src = rects.get(fromId);
if (!src || !fan.length) return;

const baseY = baseCorridorY(src, srcLane, tgtLane, lanes);
const corridorY = baseY + sourceIndex * CORRIDOR_STEP;
const sharedExit = HORIZONTAL_STUB;
const srcRight = src.x + src.width;
const isFan = fan.length > 1;
const entrySpread = isFan ? (fan.length - 1) * CORRIDOR_STEP : 0;

const fanMetas = fan.map((edge, fanIndex) => {
const tgt = rects.get(edge.toId);
const corridorEntryOffset = isFan
? -(HORIZONTAL_STUB + entrySpread) + fanIndex * CORRIDOR_STEP
: -HORIZONTAL_STUB;
const entryXLayout = (tgt?.x ?? 0) + corridorEntryOffset;
return { edge, corridorEntryOffset, entryXLayout };
});

let sharedBranchOffset: number | undefined;
if (isFan && fanMetas.length) {
const minEntryX = Math.min(...fanMetas.map((m) => m.entryXLayout));
sharedBranchOffset = minEntryX - srcRight;
}

fanMetas.forEach(({ edge, corridorEntryOffset }) => {
corridorMetaByKey.set(edgeKey(edge.fromId, edge.toId), {
corridorY,
corridorExitOffset: sharedExit,
corridorEntryOffset,
corridorBranchOffset: sharedBranchOffset,
});
});
});
});

// Separate vertical drops into the same target column (stacked nodes),
// without breaking shared-trunk exit / branch for same-source fans.
const byTargetColumn = new Map<string, RoutedTraceFlowEdge[]>();
corridorCandidates.forEach((edge) => {
const tgt = rects.get(edge.toId);
if (!tgt) return;
const key = targetColumnKey(tgt);
const list = byTargetColumn.get(key) ?? [];
list.push(edge);
byTargetColumn.set(key, list);
});

byTargetColumn.forEach((edges) => {
if (edges.length <= 1) return;

const sorted = [...edges].sort((a, b) => {
const aty = rects.get(a.toId)?.y ?? 0;
const bty = rects.get(b.toId)?.y ?? 0;
return aty - bty || edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId));
});

const spread = (sorted.length - 1) * CORRIDOR_STEP;

sorted.forEach((edge, index) => {
const key = edgeKey(edge.fromId, edge.toId);
const meta = corridorMetaByKey.get(key);
if (!meta) return;
// Same-source fans already have entry offsets relative to the fan.
if ((outDegree.get(edge.fromId) ?? 0) > 1) return;
corridorMetaByKey.set(key, {
...meta,
corridorEntryOffset: -(HORIZONTAL_STUB + spread) + index * CORRIDOR_STEP,
});
});
});

// Separate trunks when multiple distinct sources share a column (stacked nodes).
// Same-source fan edges keep a shared exit; different sources get staggered exits.
const bySourceColumn = new Map<string, string[]>();
corridorCandidates.forEach((edge) => {
const src = rects.get(edge.fromId);
if (!src) return;
const key = sourceColumnKey(src);
const list = bySourceColumn.get(key) ?? [];
if (!list.includes(edge.fromId)) list.push(edge.fromId);
bySourceColumn.set(key, list);
});

bySourceColumn.forEach((sourceIds) => {
if (sourceIds.length <= 1) return;

const sortedSources = [...sourceIds].sort((a, b) => {
const asy = rects.get(a)?.y ?? 0;
const bsy = rects.get(b)?.y ?? 0;
return asy - bsy || a.localeCompare(b);
});

const spread = (sortedSources.length - 1) * CORRIDOR_STEP;

sortedSources.forEach((fromId, index) => {
const exitOffset = HORIZONTAL_STUB - spread / 2 + index * CORRIDOR_STEP;
corridorCandidates
.filter((e) => e.fromId === fromId)
.forEach((edge) => {
const key = edgeKey(edge.fromId, edge.toId);
const meta = corridorMetaByKey.get(key);
if (!meta) return;
corridorMetaByKey.set(key, {
...meta,
corridorExitOffset: exitOffset,
});
});
});
});

return routed.map((edge) => {
const meta = corridorMetaByKey.get(edgeKey(edge.fromId, edge.toId));
if (!meta) return edge;
return {
...edge,
pathMode: "corridor",
corridorY: meta.corridorY,
corridorExitOffset: meta.corridorExitOffset,
corridorEntryOffset: meta.corridorEntryOffset,
corridorBranchOffset: meta.corridorBranchOffset,
// Shared trunk fans already leave from out-0 with zero smooth offset.
offset: (outDegree.get(edge.fromId) ?? 0) > 1 ? 0 : edge.offset,
sourceHandle:
(outDegree.get(edge.fromId) ?? 0) > 1
? `${TRACE_FLOW_HANDLE_OUT}-0`
: edge.sourceHandle,
};
});
};

/**
* Orthogonal path: exit source → drop to corridor → travel →
* (optional late branch) → rise to target.
*/
export const buildTraceFlowCorridorPath = (
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
corridorY: number,
corridorEntryOffset?: number,
corridorExitOffset?: number,
corridorBranchOffset?: number,
): string => {
const exitX = sourceX + (corridorExitOffset ?? HORIZONTAL_STUB);
const entryX = targetX + (corridorEntryOffset ?? -HORIZONTAL_STUB);
const midY = corridorY;
const branchX =
corridorBranchOffset != null ? sourceX + corridorBranchOffset : entryX;

// Keep branch between exit and entry so the trunk never backtracks oddly.
const clampedBranchX = Math.min(
Math.max(branchX, Math.min(exitX, entryX)),
Math.max(exitX, entryX),
);

if (Math.abs(clampedBranchX - entryX) < 0.5) {
return [
`M ${sourceX} ${sourceY}`,
`L ${exitX} ${sourceY}`,
`L ${exitX} ${midY}`,
`L ${entryX} ${midY}`,
`L ${entryX} ${targetY}`,
`L ${targetX} ${targetY}`,
].join(" ");
}

return [
`M ${sourceX} ${sourceY}`,
`L ${exitX} ${sourceY}`,
`L ${exitX} ${midY}`,
`L ${clampedBranchX} ${midY}`,
`L ${entryX} ${midY}`,
`L ${entryX} ${targetY}`,
`L ${targetX} ${targetY}`,
].join(" ");
};

/** Vertical position (%) for handle index among `count` siblings on one node side. */
export const traceFlowHandleTopPercent = (index: number, count: number): string => {
if (count <= 1) return "50%";
return `${((index + 1) / (count + 1)) * 100}%`;
};

+ 200
- 0
src/components/ItemTracing/traceFlowLayout.ts Ver arquivo

@@ -0,0 +1,200 @@
import {
COLUMN_WIDTH_MIN,
HEADER_HEIGHT,
LANE_GAP,
LANE_MIN_HEIGHT,
NODE_GAP,
NODE_HEIGHT,
NODE_WIDTH,
NODE_WIDTH_BRANCH,
} from "./traceFlowConstants";
import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout";
import { isFlowGroupContainer } from "./traceDoGroupLayout";

export const CELL_PAD_X = 10;
export const CELL_PAD_Y = 10;

export type CellPlacement = { x: number; y: number; compact: boolean };

const horizWidth = (count: number, nodeW: number) =>
count * nodeW + Math.max(0, count - 1) * NODE_GAP;

const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2;

const nodeLayoutSize = (
node: TraceGraphLayoutNode,
compact = false,
): { width: number; height: number } => {
if (isFlowGroupContainer(node)) {
return {
width: node.groupBoxWidth ?? NODE_WIDTH,
height: node.groupBoxHeight ?? NODE_HEIGHT,
};
}
return {
width: compact ? NODE_WIDTH_BRANCH : NODE_WIDTH,
height: NODE_HEIGHT,
};
};

const stackedCellHeight = (members: TraceGraphLayoutNode[]): number =>
members.reduce((sum, node, index) => {
const { height } = nodeLayoutSize(node);
return sum + height + (index > 0 ? NODE_GAP : 0);
}, 0);

/** Group boxes have variable height — never tile them horizontally with other cards. */
const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean =>
members.length > 1 && members.some(isFlowGroupContainer);

const layoutStackedMembers = (
sorted: TraceGraphLayoutNode[],
innerW: number,
compact: boolean,
): Map<string, CellPlacement> => {
const result = new Map<string, CellPlacement>();
let y = CELL_PAD_Y;
sorted.forEach((node) => {
const { width, height } = nodeLayoutSize(node, compact);
result.set(node.id, {
x: Math.max(0, (innerW - width) / 2),
y,
compact,
});
y += height + NODE_GAP;
});
return result;
};

/** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */
export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => {
const sorted = [...members].sort(sortNodesInPhase);
const n = sorted.length;
if (n === 0) return COLUMN_WIDTH_MIN;

if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2);
}

if (n === 1) return COLUMN_WIDTH_MIN;

if (requiresStackedLayout(sorted)) {
const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width));
return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2);
}

const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2;
const branchInner = horizWidth(n, NODE_WIDTH_BRANCH);
const fullInner = horizWidth(n, NODE_WIDTH);
const contentInner = Math.max(branchInner, fullInner, innerMin);
return contentInner + CELL_PAD_X * 2;
};

/** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */
export const layoutCellMembers = (
members: TraceGraphLayoutNode[],
innerCellWidth = defaultInnerCellWidth(),
): Map<string, CellPlacement> => {
const sorted = [...members].sort(sortNodesInPhase);
const result = new Map<string, CellPlacement>();
const n = sorted.length;
if (n === 0) return result;

const innerW = innerCellWidth;

if (n === 1 && isFlowGroupContainer(sorted[0]!)) {
const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH;
result.set(sorted[0]!.id, {
x: Math.max(0, (innerW - w) / 2),
y: CELL_PAD_Y,
compact: false,
});
return result;
}

if (n === 1) {
result.set(sorted[0].id, {
x: (innerW - NODE_WIDTH) / 2,
y: CELL_PAD_Y,
compact: false,
});
return result;
}

if (requiresStackedLayout(sorted)) {
return layoutStackedMembers(sorted, innerW, false);
}

let horizontal = false;
let nodeW = NODE_WIDTH_BRANCH;
if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) {
horizontal = true;
nodeW = NODE_WIDTH_BRANCH;
} else if (horizWidth(n, NODE_WIDTH) <= innerW) {
horizontal = true;
nodeW = NODE_WIDTH;
}

if (horizontal) {
const totalW = horizWidth(n, nodeW);
const startX = (innerW - totalW) / 2;
sorted.forEach((node, i) => {
result.set(node.id, {
x: startX + i * (nodeW + NODE_GAP),
y: CELL_PAD_Y,
compact: true,
});
});
return result;
}

return layoutStackedMembers(sorted, innerW, true);
};

export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => {
const sorted = [...members].sort(sortNodesInPhase);
const memberCount = sorted.length;
if (memberCount <= 0) return LANE_MIN_HEIGHT;
if (memberCount === 1) {
const sole = sorted[0]!;
if (isFlowGroupContainer(sole) && sole.groupBoxHeight) {
return CELL_PAD_Y * 2 + sole.groupBoxHeight;
}
return CELL_PAD_Y * 2 + NODE_HEIGHT;
}

if (requiresStackedLayout(sorted)) {
return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
}

const innerW = defaultInnerCellWidth();
const fitsHoriz =
horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW ||
horizWidth(memberCount, NODE_WIDTH) <= innerW;

if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT;
return CELL_PAD_Y * 2 + stackedCellHeight(sorted);
};

export const computeLaneTops = (
laneCount: number,
cells: Map<string, TraceGraphLayoutNode[]>,
): { laneTops: number[]; laneHeights: number[]; totalHeight: number } => {
const laneHeights = Array.from({ length: laneCount }, () => LANE_MIN_HEIGHT);

cells.forEach((members, key) => {
const laneIndex = Number(key.split(":")[0]);
const cellH = cellContentHeight(members);
laneHeights[laneIndex] = Math.max(laneHeights[laneIndex] ?? LANE_MIN_HEIGHT, cellH);
});

const laneTops: number[] = [];
let y = HEADER_HEIGHT;
for (let i = 0; i < laneCount; i++) {
laneTops[i] = y;
y += laneHeights[i] ?? LANE_MIN_HEIGHT;
if (i < laneCount - 1) y += LANE_GAP;
}

return { laneTops, laneHeights, totalHeight: y + 16 };
};

+ 223
- 0
src/components/ItemTracing/traceFlowNodeUtils.ts Ver arquivo

@@ -0,0 +1,223 @@
import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
import { TraceGraphPhase } from "./traceGraphLayout";

/**
* Palette aligned with phase/category so kinds in different lanes do not all
* collapse onto the same chip color (outbound vs pick vs scrap vs fail).
*/
export const kindColor = (
kind: TraceGraphNodeKind,
):
| "success"
| "warning"
| "info"
| "secondary"
| "default"
| "primary"
| "error" => {
switch (kind) {
case "PURCHASE":
case "RECEIPT":
case "MATERIAL_IN":
case "OPEN":
case "IN":
return "success";
case "QC":
case "MATERIAL_QC":
return "info";
case "FAIL":
return "error";
case "MATERIAL_PICK":
case "PICK_GROUP":
return "secondary";
case "PRODUCTION_STEP":
case "JO_CREATED":
case "BYPRODUCT":
return "primary";
case "SCRAP":
return "warning";
case "DEFECT":
case "EXPIRED":
return "error";
case "PUTAWAY":
case "TRANSFER":
case "REPACK":
return "primary";
case "STOCK_TAKE":
return "secondary";
case "ADJUSTMENT":
case "DEPLETED":
return "default";
case "DO_OUT":
case "DO_GROUP":
case "REPLENISHMENT_CREATED":
case "OUT":
return "warning";
case "JO_OUT":
case "PO_OUT":
return "secondary";
case "RETURN":
return "error";
default:
return "default";
}
};

export const kindLabelKey = (kind: TraceGraphNodeKind): string => {
switch (kind) {
case "MATERIAL_IN":
return "nodeMaterialIn";
case "JO_CREATED":
return "nodeJoCreated";
case "MATERIAL_QC":
return "tabQc";
case "MATERIAL_PICK":
return "nodeMaterialPick";
case "PRODUCTION_STEP":
return "nodeProductionStep";
case "BYPRODUCT":
return "nodeByproduct";
case "SCRAP":
return "nodeScrap";
case "DEFECT":
return "nodeDefect";
case "OPEN":
return "nodeOpen";
case "FAIL":
return "nodeFail";
case "DO_OUT":
return "nodeDoOut";
case "REPLENISHMENT_CREATED":
return "nodeReplenishmentCreated";
case "JO_OUT":
return "nodeJoOut";
case "PO_OUT":
return "nodePoOut";
case "DO_GROUP":
return "nodeDoGroup";
case "PICK_GROUP":
return "nodePickGroup";
case "RETURN":
return "nodeReturn";
case "REPACK":
return "nodeRepack";
case "PURCHASE":
return "nodePurchase";
case "RECEIPT":
return "nodeReceipt";
case "PUTAWAY":
return "nodePutaway";
case "IN":
return "directionIn";
case "OUT":
return "directionOut";
case "QC":
return "tabQc";
case "STOCK_TAKE":
return "nodeStockTake";
case "ADJUSTMENT":
return "nodeAdjustment";
case "TRANSFER":
return "nodeTransfer";
case "EXPIRED":
return "nodeExpired";
case "DEPLETED":
return "nodeDepleted";
default:
return "type";
}
};

export const phaseLabelKey = (phase: TraceGraphPhase): string => {
switch (phase) {
case "MATERIAL_PICK":
return "phaseMaterialPick";
case "PRODUCTION":
return "phaseProduction";
case "PURCHASE":
return "phasePurchase";
case "INBOUND":
return "phaseInbound";
case "QC":
return "phaseQc";
case "PUTAWAY":
return "phasePutaway";
case "WAREHOUSE":
return "phaseWarehouse";
case "OUTBOUND":
return "phaseOutbound";
case "STOCK_TAKE":
return "phaseStockTake";
default:
return "type";
}
};

export const phaseAccent = (phase: TraceGraphPhase): string => {
switch (phase) {
case "MATERIAL_PICK":
return "#f9a825";
case "PRODUCTION":
return "#6a1b9a";
case "PURCHASE":
return "#33691e";
case "INBOUND":
return "#2e7d32";
case "QC":
return "#0288d1";
case "PUTAWAY":
return "#00897b";
case "WAREHOUSE":
return "#7b1fa2";
case "OUTBOUND":
return "#ed6c02";
case "STOCK_TAKE":
return "#5c6bc0";
default:
return "#757575";
}
};

/** MUI Chip color for a phase — matches the node chips typically shown in that lane. */
export const phaseChipColor = (
phase: TraceGraphPhase,
):
| "success"
| "warning"
| "info"
| "secondary"
| "default"
| "primary"
| "error" => {
switch (phase) {
case "PURCHASE":
case "INBOUND":
return "success";
case "QC":
return "info";
case "PUTAWAY":
case "PRODUCTION":
case "WAREHOUSE":
return "primary";
case "MATERIAL_PICK":
case "STOCK_TAKE":
return "secondary";
case "OUTBOUND":
return "warning";
default:
return "default";
}
};

export const minimapNodeColor = (kind: TraceGraphNodeKind): string => {
const map: Record<string, string> = {
success: "#2e7d32",
warning: "#ed6c02",
info: "#0288d1",
secondary: "#5c6bc0",
primary: "#7b1fa2",
default: "#9e9e9e",
error: "#d32f2f",
};
return map[kindColor(kind)] ?? "#9e9e9e";
};

+ 291
- 0
src/components/ItemTracing/traceGraphLabels.ts Ver arquivo

@@ -0,0 +1,291 @@
import { TFunction } from "i18next";
import { createTraceLabelTranslator } from "./traceLabelUtils";
import { formatQty } from "./traceQtyUtils";
import type { JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes";
import type { ProductionGraphLabels } from "./buildProductionGraphNodes";
import type { ExtendedTraceGraphLabels } from "./buildExtendedTraceGraphNodes";
import type { TraceGraphDetailLabels } from "./buildTraceGraphNodes";

export type TraceGraphCompileLabels = TraceGraphDetailLabels &
JoPreludeGraphLabels &
ProductionGraphLabels &
ExtendedTraceGraphLabels & {
flowDoGroupTitle?: (count: number) => string;
flowPickGroupTitle?: (pickOrderCode: string, count: number) => string;
};

export const buildTraceGraphLabels = (
t: TFunction,
lotUom: string,
): TraceGraphCompileLabels => {
const tr = createTraceLabelTranslator(t);
return {
tr,
nodeQcPass: t("nodeQcPass"),
nodeQcFail: t("nodeQcFail"),
nodeReceipt: t("nodeReceipt"),
nodePurchase: t("nodePurchase"),
nodePutaway: t("nodePutaway"),
nodePutawayTransfer: t("nodePutawayTransfer"),
putawayTransferDetail: t("putawayTransferInboundDetail"),
nodeStockTake: t("nodeStockTake"),
nodeAdjustment: t("nodeAdjustment"),
nodeTransfer: t("nodeTransfer"),
nodeMaterialIn: t("nodeMaterialIn"),
nodeJoCreated: t("nodeJoCreated"),
nodeMaterialPick: t("nodeMaterialPick"),
nodeProductionStep: t("nodeProductionStep"),
nodeByproduct: t("nodeByproduct"),
nodeScrap: t("nodeScrap"),
nodeDefect: t("nodeDefect"),
detailProcessOutputQty: t("processOutputQty"),
detailProcessScrapQty: t("processScrapQty"),
detailProcessDefectQty: t("processDefectQty"),
nodeOpen: t("nodeOpen"),
nodeFail: t("nodeFail"),
nodeDoOut: t("nodeDoOut"),
nodeReplenishmentCreated: t("nodeReplenishmentCreated"),
nodeJoOut: t("nodeJoOut"),
nodePoOut: t("nodePoOut"),
doOutboundExtra: t("doOutboundExtra"),
doOutboundReplenish: t("doOutboundReplenish"),
detailDoOutboundKind: t("detailDoOutboundKind"),
flowDoGroupTitle: (count: number) => t("flowDoGroupTitle", { count }),
flowPickGroupTitle: (pickOrderCode: string, count: number) =>
t("flowPickGroupTitle", { pickOrderCode, count }),
nodeReturn: t("nodeReturn"),
nodeRepack: t("nodeRepack"),
traceRepackLot: t("traceRepackLot"),
nodeExpired: t("nodeExpired"),
nodeDepleted: t("nodeDepleted"),
directionIn: t("directionIn"),
directionOut: t("directionOut"),
formatQcSubtitle: (failQty: number, acceptedQty: number) =>
`${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`,
detailQty: t("qty"),
detailTime: t("timestamp"),
detailHandler: t("handler"),
detailStockTaker: t("stockTaker"),
detailApprover: t("approver"),
detailWarehouse: t("from"),
detailRemarks: t("remarks"),
detailFailCategory: t("detailFailCategory"),
detailProcessDescription: t("detailProcessDescription"),
detailType: t("type"),
detailRef: t("ref"),
detailStockTakeCode: t("stockTakeCode"),
detailAdjustmentRef: t("detailAdjustmentRef"),
detailReplenishmentCode: t("detailReplenishmentCode"),
detailReason: t("detailReason"),
detailReturnRef: t("detailReturnRef"),
detailSourceDoc: t("detailSourceDoc"),
detailPurchaseOrderNo: t("detailPurchaseOrderNo"),
detailInboundRef: t("detailInboundRef"),
detailInboundSiNo: t("detailInboundSiNo"),
detailPutawayBin: t("detailPutawayBin"),
detailDirection: t("detailDirection"),
detailStatus: t("status"),
detailSupplier: t("supplier"),
detailMaterial: t("material"),
detailItemCode: t("detailItemCode"),
detailItemName: t("detailItemName"),
detailLot: t("lotNo"),
detailAcceptedQty: t("acceptedQty"),
detailFailQty: t("failQty"),
detailQcCriteria: t("detailQcCriteria"),
detailQcType: t("detailQcType"),
detailQcUnknownItem: t("detailQcUnknownItem"),
detailFrom: t("from"),
detailTo: t("to"),
detailVariance: t("variance"),
detailBefore: t("before"),
detailAfter: t("after"),
detailStockTakeFirstCount: t("stockTakeStageFirstCount"),
detailStockTakeSecondCount: t("stockTakeStageSecondCount"),
detailStockTakeApproverCount: t("stockTakeStageApproverCount"),
detailScrapQty: t("scrapQty"),
detailDefectQty: t("defectQty"),
detailEquipment: t("equipment"),
detailProcessStep: t("processStep"),
detailStepMaterials: t("detailStepMaterials"),
detailAssignedStep: t("detailAssignedStep"),
detailPickTargetDate: t("detailPickTargetDate"),
detailProductLotNo: t("productLotNo"),
deliveryOrder: t("deliveryOrder"),
deliveryNoteCode: t("deliveryNoteCode"),
ticketNo: t("ticketNo"),
pickOrder: t("pickOrder"),
jobOrder: t("jobOrder"),
expiryDate: t("expiryDate"),
totalAvailable: t("totalAvailable"),
itemCode: t("itemCode"),
detailOrderQty: t("detailOrderQty"),
detailPutAwayQty: t("detailPutAwayQty"),
detailPurchaseUnit: t("detailPurchaseUnit"),
detailSupplyTo: t("detailSupplyTo"),
detailStockTakeRound: t("detailStockTakeRound"),
detailStockTakeSection: t("detailStockTakeSection"),
detailLocation: t("detailLocation"),
categoryPurchase: t("categoryPurchase"),
categoryProduction: t("categoryProduction"),
categoryInbound: t("phaseInbound"),
categoryReceipt: t("categoryReceipt"),
categoryPutaway: t("phasePutaway"),
categoryTransfer: t("phaseWarehouse"),
categoryStockTake: t("phaseStockTake"),
categoryOpen: t("categoryOpen"),
categoryAdjustment: t("nodeAdjustment"),
categoryPick: t("phaseMaterialPick"),
categoryQc: t("phaseQc"),
categoryOutbound: t("phaseOutbound"),
categoryTerminal: t("categoryTerminal"),
processingStatus: t("processingStatus"),
matchStatus: t("matchStatus"),
};
};

/** Minimal labels for unit tests (no i18n). */
export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => {
const identity = (s: string | null | undefined) => s?.trim() || "—";
const tr = {
refType: identity,
movementType: identity,
direction: (c: string | null | undefined) =>
c?.toUpperCase() === "OUT" ? "Out" : c?.toUpperCase() === "IN" ? "In" : identity(c),
stockInStatus: identity,
adjustmentType: identity,
usageType: identity,
failType: identity,
productionStatus: identity,
pickStatus: identity,
joStatus: identity,
qcType: () => "IQC",
qcPassed: (p: boolean) => (p ? "Pass" : "Fail"),
putawayShelfStatus: (phase: "pending" | "completed") =>
phase === "completed" ? "Put away done" : "Pending put-away",
processingStatus: identity,
matchStatus: identity,
lotLineStatus: identity,
};
return {
tr,
nodeQcPass: "QC Pass",
nodeQcFail: "QC Fail",
nodeReceipt: "Receipt",
nodePurchase: "Purchase",
nodePutaway: "Putaway",
nodePutawayTransfer: "Transfer inbound",
putawayTransferDetail: "Inbound at target warehouse after transfer (auto-completed, not PO put-away queue)",
nodeStockTake: "Stock take",
nodeAdjustment: "Adjustment",
nodeTransfer: "Transfer",
nodeMaterialIn: "Material in",
nodeJoCreated: "JO created",
nodeMaterialPick: "Material pick",
nodeProductionStep: "Production step",
nodeByproduct: "Byproduct",
nodeScrap: "Scrap",
nodeDefect: "Defect",
detailProcessOutputQty: "Process output",
detailProcessScrapQty: "Scrap quantity",
detailProcessDefectQty: "Defect quantity",
nodeOpen: "Opening",
nodeFail: "Pick fail",
nodeDoOut: "DO PO",
nodeReplenishmentCreated: "Replenishment created",
nodeJoOut: "JO PO",
nodePoOut: "PO pick",
doOutboundExtra: "Add-on",
doOutboundReplenish: "Replenishment",
detailDoOutboundKind: "Outbound type",
flowDoGroupTitle: (count) => `DO × ${count}`,
flowPickGroupTitle: (code, count) => `Pick · ${code} × ${count}`,
nodeReturn: "Return",
nodeRepack: "Repack",
traceRepackLot: "Trace lot",
nodeExpired: "Expired",
nodeDepleted: "Depleted",
directionIn: "In",
directionOut: "Out",
formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`,
detailQty: "Qty",
detailTime: "Time",
detailHandler: "Handler",
detailStockTaker: "First counter",
detailApprover: "Approver",
detailWarehouse: "Warehouse",
detailRemarks: "Remarks",
detailFailCategory: "Fail category",
detailProcessDescription: "Step description",
detailType: "Type",
detailRef: "Doc no.",
detailStockTakeCode: "Stock Take #",
detailAdjustmentRef: "Adjustment #",
detailReplenishmentCode: "Replenishment #",
detailReason: "Reason",
detailReturnRef: "Return #",
detailSourceDoc: "Source doc #",
detailPurchaseOrderNo: "PO no.",
detailInboundRef: "Inbound SI",
detailInboundSiNo: "Inbound SI no.",
detailPutawayBin: "Bin",
detailDirection: "Direction",
detailStatus: "Status",
detailSupplier: "Supplier",
detailMaterial: "Material",
detailItemCode: "Item code",
detailItemName: "Item name",
detailLot: "Lot",
detailAcceptedQty: "Accepted",
detailFailQty: "Fail",
detailQcCriteria: "Criteria",
detailQcType: "QC type",
detailQcUnknownItem: "Unknown",
detailFrom: "From",
detailTo: "To",
detailVariance: "Variance",
detailBefore: "Book qty",
detailAfter: "Accepted qty",
detailStockTakeFirstCount: "First count",
detailStockTakeSecondCount: "Re-count",
detailStockTakeApproverCount: "Approver count",
detailScrapQty: "Scrap",
detailDefectQty: "Defect",
detailEquipment: "Equipment",
detailProcessStep: "Step",
detailStepMaterials: "Step materials",
detailAssignedStep: "Assigned step",
detailPickTargetDate: "Target date",
detailProductLotNo: "Product lot",
deliveryOrder: "DO",
deliveryNoteCode: "DN",
ticketNo: "Ticket",
pickOrder: "Pick",
jobOrder: "Job order",
expiryDate: "Expiry",
totalAvailable: "Available",
itemCode: "Item code",
detailOrderQty: "Order qty",
detailPutAwayQty: "Putaway qty",
detailPurchaseUnit: "Purchase unit",
detailSupplyTo: "Supply to",
detailStockTakeRound: "Stock take round",
detailStockTakeSection: "Stock take section",
detailLocation: "Location",
categoryPurchase: "Purchase",
categoryProduction: "Production",
categoryInbound: "Inbound",
categoryReceipt: "Receipt",
categoryPutaway: "Putaway",
categoryTransfer: "Warehouse",
categoryStockTake: "Stock take",
categoryOpen: "Opening",
categoryAdjustment: "Adjustment",
categoryPick: "Pick",
categoryQc: "QC",
categoryOutbound: "Outbound",
categoryTerminal: "Terminal",
processingStatus: "Processing status",
matchStatus: "Match status",
};
};

+ 402
- 0
src/components/ItemTracing/traceGraphLayout.ts Ver arquivo

@@ -0,0 +1,402 @@
import dayjs from "dayjs";
import { ItemLotTraceResponse } from "@/app/api/itemTracing";
import { COLUMN_WIDTH_MIN } from "./traceFlowConstants";
import {
buildTraceGraphNodes,
TraceGraphDetailLabels,
TraceGraphNode,
} from "./buildTraceGraphNodes";
import { buildJoPreludeGraphNodes, JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes";
import {
buildProductionGraphNodes,
ProductionGraphLabels,
} from "./buildProductionGraphNodes";
import {
buildExtendedTraceGraphNodes,
ExtendedTraceGraphLabels,
} from "./buildExtendedTraceGraphNodes";
import { buildAllLocationBlockGraphNodes } from "./buildLocationBlockGraphNodes";
import { applyFlowNodeGrouping, isDoGroupChild, regroupLayoutCells } from "./traceDoGroupLayout";
import { cellWidthRequirement } from "./traceFlowLayout";
import {
materialInputsHaveProduction,
phaseFromKind,
sortNodesInPhase,
sortPhasesByEarliestEvent,
} from "./traceGraphSemantics";

export {
buildTraceFlowEdgePairs,
materialInputsHaveProduction,
phaseFromKind,
sortNodesInPhase,
sortPhasesByEarliestEvent,
} from "./traceGraphSemantics";

export type PreProductionPhase = "MATERIAL_PICK";

export type ProductionPhase = "PRODUCTION";

export type FgTraceGraphPhase =
| "PURCHASE"
| "INBOUND"
| "QC"
| "PUTAWAY"
| "WAREHOUSE"
| "OUTBOUND"
| "STOCK_TAKE";

/** @deprecated MATERIAL_QC merged into QC lane when prelude is shown */
export type LegacyMaterialQcPhase = "MATERIAL_QC";

export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase;

export const PRE_PRODUCTION_PHASES: PreProductionPhase[] = ["MATERIAL_PICK"];

export const FG_PHASE_ORDER: FgTraceGraphPhase[] = [
"PURCHASE",
"INBOUND",
"QC",
"PUTAWAY",
"WAREHOUSE",
"STOCK_TAKE",
"OUTBOUND",
];

/** Prelude rows use one shared QC lane (原料 + 成品品檢). */
export const PRELUDE_PHASE_ORDER = (
hasProduction: boolean,
): TraceGraphPhase[] => {
const phases: TraceGraphPhase[] = [
"PURCHASE",
"INBOUND",
"QC",
"PUTAWAY",
"MATERIAL_PICK",
];
if (hasProduction) phases.push("PRODUCTION");
phases.push("WAREHOUSE", "STOCK_TAKE", "OUTBOUND");
return phases;
};

export const getPhaseOrder = (hasPrelude: boolean, hasProduction: boolean): TraceGraphPhase[] => {
if (hasPrelude) return PRELUDE_PHASE_ORDER(hasProduction);
const phases: TraceGraphPhase[] = [...FG_PHASE_ORDER];
if (hasProduction) phases.splice(1, 0, "PRODUCTION");
return phases;
};

/** @deprecated use layout.phaseOrder */
export const PHASE_ORDER: TraceGraphPhase[] = FG_PHASE_ORDER;

/** @deprecated use layout.laneCount */
export const LANE_COUNT = FG_PHASE_ORDER.length;

export interface TraceGraphLayoutNode extends TraceGraphNode {
phase: TraceGraphPhase;
column: number;
dayKey: string;
laneIndex: number;
sequenceIndex: number;
branchIndex: number;
branchSize: number;
/** Slot index within the day column (one dynamic phase slot per phase on that day). */
dayPhaseStaggerIndex: number;
}

export interface DayColumnLayout {
columnStarts: number[];
columnWidths: number[];
/** Width of each phase slot within a calendar day (same order as phases on that day). */
phaseSlotWidths: number[][];
/** Start X offset of each phase slot within a calendar day. */
phaseSlotStarts: number[][];
timelineWidth: number;
}

export interface TraceGraphLayout {
nodes: TraceGraphLayoutNode[];
columnCount: number;
columnDates: string[];
dayColumns: DayColumnLayout;
cellGroups: TraceGraphLayoutNode[][];
phaseOrder: TraceGraphPhase[];
laneCount: number;
hasPrelude: boolean;
hasProduction: boolean;
}

export const laneIndexFromPhase = (phase: TraceGraphPhase, phaseOrder: TraceGraphPhase[]): number =>
phaseOrder.indexOf(phase);

const cellKey = (column: number, laneIndex: number) => `${column}:${laneIndex}`;

export const dayKeyFromTimestamp = (timestamp: string | null): string => {
if (!timestamp?.trim()) return "—";
const d = dayjs(timestamp);
if (!d.isValid()) return timestamp.slice(0, 10) || "—";
return d.format("YYYY-MM-DD");
};

export const phasesOnDay = (
nodes: TraceGraphLayoutNode[],
dayKey: string,
phaseOrder: TraceGraphPhase[],
): TraceGraphPhase[] => {
const present = new Set<TraceGraphPhase>();
nodes.forEach((n) => {
if (n.dayKey === dayKey) present.add(n.phase);
});
return sortPhasesByEarliestEvent(nodes, Array.from(present), phaseOrder, dayKey);
};

/** Widen each calendar day by chronological time slots (not phase-packed slots). */
export const computeDayColumnLayout = (
columnDates: string[],
nodes: TraceGraphLayoutNode[],
_phaseOrder: TraceGraphPhase[],
): DayColumnLayout => {
const visible = nodes.filter((n) => !isDoGroupChild(n));
const phaseSlotWidths: number[][] = [];
const phaseSlotStarts: number[][] = [];
const columnWidths: number[] = [];

columnDates.forEach((dayKey, colIndex) => {
const dayNodes = visible.filter((n) => n.column === colIndex && n.dayKey === dayKey);
const maxSlot = dayNodes.reduce((m, n) => Math.max(m, n.dayPhaseStaggerIndex), 0);
const slotCount = dayNodes.length === 0 ? 1 : maxSlot + 1;

const slotWidths: number[] = [];
for (let slot = 0; slot < slotCount; slot++) {
const members = dayNodes.filter((n) => n.dayPhaseStaggerIndex === slot);
slotWidths.push(cellWidthRequirement(members));
}

const starts: number[] = [];
let dayWidth = 0;
slotWidths.forEach((w) => {
starts.push(dayWidth);
dayWidth += w;
});

phaseSlotWidths.push(slotWidths);
phaseSlotStarts.push(starts);
columnWidths.push(Math.max(COLUMN_WIDTH_MIN, dayWidth));
});

const columnStarts: number[] = [];
let x = 0;
for (const w of columnWidths) {
columnStarts.push(x);
x += w;
}
return { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts, timelineWidth: x };
};

/**
* Within each calendar day, assign left→right slots by event time (sortKey).
* Avoids packing an entire phase to the left of another phase when later events
* in the early phase happen after earlier events in a later phase.
*/
export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => {
const byDay = new Map<string, TraceGraphLayoutNode[]>();
nodes.forEach((n) => {
if (isDoGroupChild(n)) return;
const list = byDay.get(n.dayKey) ?? [];
list.push(n);
byDay.set(n.dayKey, list);
});

byDay.forEach((dayNodes) => {
dayNodes.sort((a, b) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
// Same timestamp: putaway before material ADJUSTMENT only (上架 → 庫存調整).
if (
(a.kind === "PUTAWAY" || a.kind === "ADJUSTMENT") &&
(b.kind === "PUTAWAY" || b.kind === "ADJUSTMENT") &&
a.kind !== b.kind
) {
return a.kind === "PUTAWAY" ? -1 : 1;
}
// 建立工單 before 工單提料 group / lines in the same pick phase.
const pickLaneOrder = (n: TraceGraphLayoutNode) =>
n.kind === "JO_CREATED" ? 0 : n.kind === "PICK_GROUP" ? 1 : n.kind === "MATERIAL_PICK" ? 2 : 3;
const pickA = pickLaneOrder(a);
const pickB = pickLaneOrder(b);
if (pickA !== pickB) return pickA - pickB;
// Then canonical phase / lane order (e.g. QC before putaway).
if (a.laneIndex !== b.laneIndex) return a.laneIndex - b.laneIndex;
if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex;
return a.id.localeCompare(b.id);
});
dayNodes.forEach((n, i) => {
n.dayPhaseStaggerIndex = i;
n.branchIndex = 0;
n.branchSize = 1;
});
});

// Group children share the container's horizontal time slot.
const parentById = new Map(nodes.map((n) => [n.id, n]));
nodes.forEach((n) => {
if (!n.doGroupId) return;
const parent = parentById.get(n.doGroupId);
if (parent) n.dayPhaseStaggerIndex = parent.dayPhaseStaggerIndex;
});
};

const assignColumns = (
sorted: TraceGraphNode[],
phaseOrder: TraceGraphPhase[],
): { columnDates: string[]; nodes: TraceGraphLayoutNode[]; cellGroups: TraceGraphLayoutNode[][] } => {
const dayOrder: string[] = [];
const columnByDayKey = new Map<string, number>();

const withMeta: TraceGraphLayoutNode[] = sorted.map((node, sequenceIndex) => {
const dayKey = dayKeyFromTimestamp(node.timestamp);
let column = columnByDayKey.get(dayKey);
if (column === undefined) {
column = dayOrder.length;
dayOrder.push(dayKey);
columnByDayKey.set(dayKey, column);
}
const phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo, node.refType);
return {
...node,
phase,
dayKey,
column,
laneIndex: laneIndexFromPhase(phase, phaseOrder),
sequenceIndex,
branchIndex: 0,
branchSize: 1,
dayPhaseStaggerIndex: 0,
};
});

// Provisional phase-based stagger (overwritten after grouping by chronological slots).
const staggerByDay = new Map<string, TraceGraphPhase[]>();
withMeta.forEach((node) => {
if (!staggerByDay.has(node.dayKey)) {
staggerByDay.set(node.dayKey, phasesOnDay(withMeta, node.dayKey, phaseOrder));
}
const ordered = staggerByDay.get(node.dayKey)!;
node.dayPhaseStaggerIndex = Math.max(0, ordered.indexOf(node.phase));
});

const groups = new Map<string, TraceGraphLayoutNode[]>();
withMeta.forEach((node) => {
const key = cellKey(node.column, node.laneIndex);
const list = groups.get(key) ?? [];
list.push(node);
groups.set(key, list);
});

groups.forEach((list) => {
list.sort(sortNodesInPhase);
const size = list.length;
list.forEach((node, idx) => {
node.branchIndex = idx;
node.branchSize = size;
});
});

const cellGroups = Array.from(groups.values()).sort((a, b) => {
const na = a[0];
const nb = b[0];
if (na.column !== nb.column) return na.column - nb.column;
return na.laneIndex - nb.laneIndex;
});

return { columnDates: dayOrder, nodes: withMeta, cellGroups };
};

export const buildTraceGraphLayout = (
data: ItemLotTraceResponse,
labels: TraceGraphDetailLabels &
Partial<JoPreludeGraphLabels> &
Partial<ProductionGraphLabels> &
Partial<ExtendedTraceGraphLabels> & {
flowDoGroupTitle?: (count: number) => string;
flowPickGroupTitle?: (pickOrderCode: string, count: number) => string;
},
): TraceGraphLayout => {
const hasPrelude = data.joPrelude != null;
const hasScrap = (data.productionSteps ?? []).some(
(s) => (s.scrapQty ?? 0) + (s.defectQty ?? 0) > 0,
);
const hasMaterialProduction =
data.joPrelude != null && materialInputsHaveProduction(data.joPrelude.materialInputs);
const hasProduction =
(data.productionSteps?.length ?? 0) > 0 ||
(data.byproductLots?.length ?? 0) > 0 ||
hasScrap ||
hasMaterialProduction;
const phaseOrder = getPhaseOrder(hasPrelude, hasProduction);
const mergedMultiLocation = (data.locationBlocks?.length ?? 0) > 0;
const primaryWh =
data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined;
const primaryScope = {
defaultWarehouseCode: primaryWh,
inventoryLotId: data.lot.inventoryLotId,
mergedMultiLocation,
};
const fgNodes = buildTraceGraphNodes(data, labels, primaryScope);
const preludeNodes =
data.joPrelude && labels.nodeMaterialIn && labels.nodeMaterialPick && labels.nodePurchase
? buildJoPreludeGraphNodes(data.joPrelude, labels as JoPreludeGraphLabels)
: [];
const productionNodes =
hasProduction && labels.nodeProductionStep && labels.nodeScrap && labels.nodeDefect
? buildProductionGraphNodes(
data.productionSteps ?? [],
data.byproductLots ?? [],
labels as ProductionGraphLabels,
data.lot.uom,
)
: [];
const extendedNodes =
labels.nodeOpen && labels.nodeFail && labels.nodeDoOut
? buildExtendedTraceGraphNodes(data, labels as ExtendedTraceGraphLabels, primaryScope)
: [];
const locationNodes =
(data.locationBlocks?.length ?? 0) > 0 &&
labels.nodeOpen &&
labels.nodeFail &&
labels.nodeDoOut
? buildAllLocationBlockGraphNodes(data.locationBlocks ?? [], labels as ExtendedTraceGraphLabels)
: [];
const merged = [...preludeNodes, ...productionNodes, ...extendedNodes, ...fgNodes, ...locationNodes].sort((a, b) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
return a.id.localeCompare(b.id);
});
const { columnDates, nodes: assigned, cellGroups: _initialCellGroups } = assignColumns(
merged,
phaseOrder,
);
const groupTitleDo =
labels.flowDoGroupTitle ?? ((count: number) => `DO × ${count}`);
const groupTitlePick =
labels.flowPickGroupTitle ??
((pickOrderCode: string, count: number) => `${pickOrderCode} × ${count}`);
const nodes = applyFlowNodeGrouping(assigned, {
flowDoGroupTitle: groupTitleDo,
flowPickGroupTitle: groupTitlePick,
});
assignChronologicalDaySlots(nodes);
const cellGroups = regroupLayoutCells(nodes);
const columnCount = Math.max(columnDates.length, 1);
const dayColumns = computeDayColumnLayout(columnDates, nodes, phaseOrder);

return {
nodes,
columnCount,
columnDates,
dayColumns,
cellGroups,
phaseOrder,
laneCount: phaseOrder.length,
hasPrelude,
hasProduction,
};
};

+ 47
- 0
src/components/ItemTracing/traceGraphSearch.ts Ver arquivo

@@ -0,0 +1,47 @@
import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
import { TraceGraphLayoutNode } from "./traceGraphLayout";

const normalize = (value: string) => value.trim().toLowerCase();

const nodeSearchHaystack = (
node: TraceGraphLayoutNode,
kindLabel: string,
): string => {
const parts: string[] = [
node.title,
node.subtitle,
node.refCode ?? "",
node.traceLotNo ?? "",
node.traceItemCode ?? "",
node.consoCode ?? "",
node.meta ?? "",
node.timestamp ?? "",
node.categoryLabel ?? "",
node.refType ?? "",
node.warehouseCode ?? "",
node.transferFromWarehouse ?? "",
node.transferToWarehouse ?? "",
kindLabel,
node.qty != null ? String(node.qty) : "",
...node.details.flatMap((field) =>
[field.label, field.value, field.linkCode ?? ""].filter(Boolean),
),
];
return normalize(parts.join(" "));
};

export const searchTraceGraphNodes = (
nodes: TraceGraphLayoutNode[],
query: string,
kindLabel: (kind: TraceGraphNodeKind) => string,
): string[] => {
const normalizedQuery = normalize(query);
if (!normalizedQuery) return [];

return nodes
.filter((node) =>
nodeSearchHaystack(node, kindLabel(node.kind)).includes(normalizedQuery),
)
.sort((a, b) => a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex)
.map((node) => node.id);
};

+ 1169
- 0
src/components/ItemTracing/traceGraphSemantics.ts
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 192
- 0
src/components/ItemTracing/traceLabelUtils.ts Ver arquivo

@@ -0,0 +1,192 @@
import { TFunction } from "i18next";
import { ItemLotTraceQcResult } from "@/app/api/itemTracing";

const normCode = (code: string | null | undefined) => (code ?? "").trim();

const lookupCode = (t: TFunction, prefix: string, code: string | null | undefined): string => {
const raw = normCode(code);
if (!raw) return "—";
const variants = [raw, raw.toUpperCase(), raw.toLowerCase()];
for (const v of variants) {
const key = `${prefix}.${v}`;
const translated = t(key, { defaultValue: "" });
if (translated && translated !== key) return translated;
}
return raw.replace(/_/g, " ");
};

export type TraceLabelTranslator = {
refType: (code: string | null | undefined) => string;
movementType: (code: string | null | undefined) => string;
direction: (code: string | null | undefined) => string;
stockInStatus: (code: string | null | undefined) => string;
adjustmentType: (code: string | null | undefined) => string;
usageType: (code: string | null | undefined) => string;
failType: (code: string | null | undefined) => string;
productionStatus: (code: string | null | undefined) => string;
pickStatus: (code: string | null | undefined) => string;
processingStatus: (code: string | null | undefined) => string;
matchStatus: (code: string | null | undefined) => string;
lotLineStatus: (code: string | null | undefined) => string;
joStatus: (code: string | null | undefined) => string;
qcType: (code: string | null | undefined) => string;
qcPassed: (passed: boolean) => string;
putawayShelfStatus: (phase: "pending" | "completed") => string;
};

export const createTraceLabelTranslator = (t: TFunction): TraceLabelTranslator => ({
refType: (code) => lookupCode(t, "code.refType", code),
movementType: (code) => lookupCode(t, "code.movementType", code),
direction: (code) => {
const u = normCode(code).toUpperCase();
if (u === "IN") return t("directionIn");
if (u === "OUT") return t("directionOut");
return lookupCode(t, "code.direction", code);
},
stockInStatus: (code) => lookupCode(t, "code.stockInStatus", code),
adjustmentType: (code) => lookupCode(t, "code.adjustmentType", code),
usageType: (code) => lookupCode(t, "code.usageType", code),
failType: (code) => lookupCode(t, "code.failType", code),
productionStatus: (code) => lookupCode(t, "code.productionStatus", code),
pickStatus: (code) => lookupCode(t, "code.pickStatus", code),
processingStatus: (code) => lookupCode(t, "code.processingStatus", code),
matchStatus: (code) => lookupCode(t, "code.matchStatus", code),
lotLineStatus: (code) => lookupCode(t, "code.lotLineStatus", code),
joStatus: (code) => lookupCode(t, "code.joStatus", code),
qcType: (code) => {
const raw = normCode(code);
const u = raw.toUpperCase();
if (!raw || u === "QC") return t("code.qcType.IQC");
return lookupCode(t, "code.qcType", code);
},
qcPassed: (passed) => (passed ? t("qcPassed") : t("qcFailed")),
putawayShelfStatus: (phase) =>
phase === "completed" ? t("putawayStatusCompleted") : t("putawayStatusPending"),
});

/** Color for 處理狀態 / 對料狀態 values on flow cards and detail panel. */
export type PickStatusValueColor = "success" | "error" | "warning" | "info" | "default";

export const pickStatusValueColor = (
code: string | null | undefined,
): PickStatusValueColor => {
switch (normCode(code).toLowerCase()) {
case "completed":
return "success";
case "rejected":
return "error";
case "scanned":
return "info";
case "pending":
case "created":
return "warning";
default:
return "default";
}
};

export const qcItemShortLabel = (q: ItemLotTraceQcResult): string =>
q.qcItemName?.trim() || q.qcItemCode?.trim() || "";

export const qcItemLabel = (q: ItemLotTraceQcResult): string => {
const name = qcItemShortLabel(q);
const desc = q.qcItemDescription?.trim();
if (name && desc) return `${name}(${desc})`;
return name || desc || "";
};

export const groupQcResultsBySession = (
results: ItemLotTraceQcResult[],
): ItemLotTraceQcResult[][] => {
const map = new Map<string, ItemLotTraceQcResult[]>();
results.forEach((q) => {
const key = `${q.stockInLineId}:${q.created ?? ""}`;
const list = map.get(key) ?? [];
list.push(q);
map.set(key, list);
});
return Array.from(map.values());
};

export const buildQcCriteriaLines = (
results: ItemLotTraceQcResult[],
tr: TraceLabelTranslator,
unknownItemLabel: string,
failQtyLabel: string,
): string => {
if (results.length === 0) return "—";
return results
.map((q) => {
const item = qcItemLabel(q) || unknownItemLabel;
const status = q.qcPassed
? tr.qcPassed(true)
: `${tr.qcPassed(false)}${q.failQty > 0 ? `(${failQtyLabel}: ${q.failQty})` : ""}`;
return `${item}:${status}`;
})
.join("\n");
};

export const buildQcSubtitle = (
results: ItemLotTraceQcResult[],
formatQcSubtitle: (failQty: number, acceptedQty: number) => string,
extra?: string,
): string => {
const first = results[0];
const qtyLine = first ? formatQcSubtitle(first.failQty, first.acceptedQty) : "";
const criteria = results
.map((q) => qcItemShortLabel(q))
.filter(Boolean)
.slice(0, 3)
.join(" · ");
const parts = [extra, criteria, qtyLine].filter(Boolean);
return parts.join(" · ") || qtyLine || "—";
};

export type DoOutboundKindLabels = {
extra: string;
replenish: string;
};

/** Detail label for 加單·補貨. */
export const formatDoOutboundKindLabel = (
isExtra?: boolean,
isReplenish?: boolean,
labels?: DoOutboundKindLabels,
): string | undefined => {
if (!labels) return undefined;
const parts: string[] = [];
if (isExtra) parts.push(labels.extra);
if (isReplenish) parts.push(labels.replenish);
return parts.length > 0 ? parts.join(" · ") : undefined;
};

import {
resolveTraceDoOutboundIsExtra,
type TraceDoOutboundExtraSource,
} from "@/utils/traceDoOutboundExtra";

export type DoOutboundChipSource = TraceDoOutboundExtraSource & {
isReplenish?: boolean;
};

/** Resolve chip flags from API fields, with ticket-type fallback (TI-E / TI-M rules). */
export const resolveDoOutboundChipFlags = (
source: DoOutboundChipSource,
): { isExtra: boolean; isReplenish: boolean } => ({
isExtra: resolveTraceDoOutboundIsExtra(source),
isReplenish: source.isReplenish === true,
});

/**
* FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20
* Outbound qty color (DO_OUT / JO_OUT / MATERIAL_PICK):
* 改數 → orange; else qty > 0 → green; qty ≤ 0 → red.
*/
export const resolveDoOutboundQtyColor = (
qty: number | null | undefined,
qtyChanged?: boolean,
): "warning.main" | "success.main" | "error.main" | undefined => {
if (qtyChanged) return "warning.main";
if (qty == null || !Number.isFinite(Number(qty))) return undefined;
return Number(qty) > 0 ? "success.main" : "error.main";
};

+ 14
- 0
src/components/ItemTracing/traceNavigationUtils.ts Ver arquivo

@@ -0,0 +1,14 @@
export type TraceNavigateParams = {
stockInLineId?: number;
lotNo?: string;
itemCode?: string;
};

export const buildItemTracingHref = (params: TraceNavigateParams): string => {
const q = new URLSearchParams();
if (params.stockInLineId != null) q.set("stockInLineId", String(params.stockInLineId));
if (params.lotNo?.trim()) q.set("lotNo", params.lotNo.trim());
if (params.itemCode?.trim()) q.set("itemCode", params.itemCode.trim());
const qs = q.toString();
return qs ? `/itemTracing?${qs}` : "/itemTracing";
};

+ 213
- 0
src/components/ItemTracing/traceNodeFactory.ts Ver arquivo

@@ -0,0 +1,213 @@
import dayjs from "dayjs";
import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import {
TraceGraphDetailField,
TraceGraphDocLinkKind,
TraceGraphNode,
} from "./buildTraceGraphNodes";
import { formatQty } from "./traceQtyUtils";
import { pickStatusValueColor } from "./traceLabelUtils";
import { normalizeTargetDateForLink } from "./traceDocLinkUtils";

export type MaterialPickLabels = {
nodeMaterialPick: string;
categoryPick: string;
pickOrder: string;
jobOrder: string;
detailMaterial: string;
detailLot: string;
detailQty: string;
detailType: string;
detailAssignedStep: string;
detailPickTargetDate: string;
detailTime: string;
processingStatus: string;
matchStatus: string;
tr: {
processingStatus: (code: string | null | undefined) => string;
matchStatus: (code: string | null | undefined) => string;
};
};

export const pickStatusDetailFields = (
labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">,
processingStatus?: string | null,
matchStatus?: string | null,
opts?: { always?: boolean },
): TraceGraphDetailField[] => {
const always = opts?.always === true;
const rows: TraceGraphDetailField[] = [];
if (always || processingStatus?.trim()) {
rows.push(
field(labels.processingStatus, labels.tr.processingStatus(processingStatus), {
valueColor: pickStatusValueColor(processingStatus),
}),
);
}
if (always || matchStatus?.trim()) {
rows.push(
field(labels.matchStatus, labels.tr.matchStatus(matchStatus), {
valueColor: pickStatusValueColor(matchStatus),
}),
);
}
return rows;
};

export const pickStatusNodeLabels = (
labels: Pick<MaterialPickLabels, "tr">,
processingStatus?: string | null,
matchStatus?: string | null,
opts?: { always?: boolean },
): Pick<
TraceGraphNode,
"processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus"
> => {
const always = opts?.always === true;
const showProcessing = always || Boolean(processingStatus?.trim());
const showMatch = always || Boolean(matchStatus?.trim());
return {
processingStatusLabel: showProcessing
? labels.tr.processingStatus(processingStatus)
: undefined,
matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined,
processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined,
matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined,
};
};

export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—");

export const field = (
label: string,
value: string | null | undefined,
extra?: Partial<TraceGraphDetailField>,
): TraceGraphDetailField => ({
label,
value: fmt(value),
...extra,
});

/** Omit detail row when value is blank (used for optional remarks, etc.). */
export const fieldIf = (
label: string,
value: string | number | null | undefined,
extra?: Partial<TraceGraphDetailField>,
): TraceGraphDetailField | null => {
const raw = value == null ? "" : String(value).trim();
if (!raw) return null;
return field(label, raw, extra);
};

export const detailsOf = (
...rows: Array<TraceGraphDetailField | null | undefined | false>
): TraceGraphDetailField[] => rows.filter((row): row is TraceGraphDetailField => Boolean(row));

export const parseSortKey = (ts: string | null | undefined, seq: number): number => {
if (!ts?.trim()) return seq;
const d = dayjs(ts);
return d.isValid() ? d.valueOf() : seq;
};

/** Expiry is date-only in master data; sort at end of that calendar day (23:59:59). */
export const parseExpirySortKey = (expiry: string | null | undefined, seq: number): number => {
if (!expiry?.trim()) return seq;
const d = dayjs(expiry.trim().slice(0, 10));
return d.isValid() ? d.endOf("day").valueOf() : seq;
};

export const docLinkFromOriginType = (type: string): TraceGraphDocLinkKind | undefined => {
const t = type.toUpperCase();
if (t === "PO") return "po";
if (t === "JO") return "jo";
return undefined;
};

export const docLinkFromRefType = (refType: string): TraceGraphDocLinkKind | undefined => {
const t = refType.toUpperCase();
if (t === "PO") return "po";
if (t === "JO") return "jo";
if (t === "PICK" || t === "DO") return "pick";
if (t === "JO_PICK") return "jodetail";
return undefined;
};

/** Semi-finished / nested JO output used as a material for another JO. */
export const isJoProducedMaterial = (m: ItemLotTraceMaterialInput): boolean =>
(m.productionSteps?.length ?? 0) > 0 ||
Boolean(m.nestedJoPrelude) ||
m.stockInOrigin?.type?.trim().toUpperCase() === "JO";

export type MaterialPickNodeContext = {
nextSeq: () => number;
pickOrderTargetDate?: (pickOrderCode: string) => string | undefined;
};

export const createMaterialPickNode = (
m: ItemLotTraceMaterialInput,
index: number,
labels: MaterialPickLabels,
ctx: MaterialPickNodeContext,
feedsProductionScopeLotNo?: string,
): TraceGraphNode => {
const matUom = m.materialUom?.trim() || "";
const pickTitle = m.pickOrderCode || labels.nodeMaterialPick;
const stepLabel = m.assignedStepName?.trim()
? m.bomProcessSeqNo != null
? `${m.bomProcessSeqNo}. ${m.assignedStepName}`
: m.assignedStepName
: "";
const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · ");
const pickTargetDate = normalizeTargetDateForLink(
ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""),
);
const pickedDate = normalizeTargetDateForLink(m.pickedAt);
const linkTargetDate = pickTargetDate || pickedDate;
return {
id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`,
kind: "MATERIAL_PICK",
timestamp: m.pickedAt,
sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()),
title: pickTitle,
subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "),
qty: m.materialQty,
uom: matUom,
meta: itemLabel || undefined,
refCode: m.pickOrderCode,
refId: m.pickOrderId,
consoCode: m.consoCode,
jobOrderCode: m.jobOrderCode?.trim() || undefined,
docLinkKind: m.pickOrderCode ? "jodetail" : undefined,
docLinkTargetDate: linkTargetDate,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
bomProcessId: m.bomProcessId,
bomProcessSeqNo: m.bomProcessSeqNo,
assignedStepName: m.assignedStepName,
feedsProductionScopeLotNo,
categoryLabel: labels.categoryPick,
details: [
field(labels.pickOrder, m.pickOrderCode, {
linkKind: "jodetail",
linkCode: m.pickOrderCode,
linkId: m.pickOrderId,
consoCode: m.consoCode,
linkTargetDate,
}),
field(labels.detailPickTargetDate, pickTargetDate || pickedDate),
field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`),
field(labels.detailLot, m.materialLotNo),
field(labels.detailQty, formatQty(m.materialQty, matUom)),
field(labels.detailType, labels.nodeMaterialPick),
field(labels.detailAssignedStep, stepLabel || "—"),
field(labels.jobOrder, m.jobOrderCode, {
linkKind: "jo",
linkCode: m.jobOrderCode,
linkId: m.jobOrderId,
}),
...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus),
field(labels.detailTime, m.pickedAt),
],
...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus),
};
};

+ 200
- 0
src/components/ItemTracing/tracePresentationAdapter.ts Ver arquivo

@@ -0,0 +1,200 @@
import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
import type { TraceGraphLayoutNode } from "./traceGraphLayout";
import type { TraceGraphNodeKind } from "./buildTraceGraphNodes";
import { mergeScopedMovements } from "./mergeLocationScopedData";

export type TraceMovementRow = {
id: string;
direction: string;
qty: number;
movementType: string;
refType: string;
refCode: string;
refId: number | null;
warehouseCode: string;
handledBy: string;
timestamp: string | null;
remarks: string;
};

export type TraceJoPickRow = {
id: string;
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
materialItemCode: string;
materialLotNo: string;
qty: number;
uom: string;
pickedAt: string | null;
bomProcessId: number | null;
assignedStepName: string;
/** YYYY-MM-DD for jodetail deep links. */
targetDate?: string;
};

export type TraceProductionRow = {
id: string;
stepName: string;
description: string;
equipmentCode: string;
equipmentName: string;
operatorName: string;
startTime: string | null;
endTime: string | null;
outputQty: number;
scrapQty: number;
defectQty: number;
status: string;
seqNo: number | null;
bomProcessId: number | null;
traceLotNo: string | null;
};

const detailValue = (node: TraceGraphLayoutNode, labelIncludes: string): string => {
const row = node.details.find((d) => d.label.includes(labelIncludes));
return row?.value?.trim() || "—";
};

const movementKinds: TraceGraphNodeKind[] = [
"IN",
"OUT",
"RECEIPT",
"OPEN",
"RETURN",
"REPACK",
];

export const deriveMovementRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
data: ItemLotTraceResponse,
): TraceMovementRow[] => {
const fromGraph = nodes
.filter((n) => movementKinds.includes(n.kind))
.map((n) => ({
id: n.id,
direction: n.kind === "OUT" || n.kind === "RETURN" ? "OUT" : "IN",
qty: n.qty ?? 0,
movementType: n.subtitle?.trim() || n.kind,
refType: n.refType?.trim() || "",
refCode: n.refCode?.trim() || "",
refId: n.refId ?? null,
warehouseCode: n.warehouseCode?.trim() || (detailValue(n, "Warehouse") !== "—" ? detailValue(n, "Warehouse") : n.meta?.split(" · ")[1] ?? ""),
handledBy: detailValue(n, "Handler") !== "—" ? detailValue(n, "Handler") : n.meta?.split(" · ")[0] ?? "",
timestamp: n.timestamp,
remarks: detailValue(n, "Remarks"),
}));

if (fromGraph.length > 0) return fromGraph;

return mergeScopedMovements(data).map((m) => ({
id: m.rowKey,
direction: m.direction,
qty: m.qty,
movementType: m.movementType,
refType: m.refType,
refCode: m.refCode,
refId: m.refId,
warehouseCode: m.scopeWarehouseCode,
handledBy: m.handledBy,
timestamp: m.timestamp,
remarks: m.remarks,
}));
};

export const deriveJoPickRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
fallbackInputs: NonNullable<ItemLotTraceResponse["joPrelude"]>["materialInputs"] | undefined,
): TraceJoPickRow[] => {
const picks = nodes.filter((n) => n.kind === "MATERIAL_PICK");
if (picks.length > 0) {
return picks.map((n) => ({
id: n.id,
pickOrderCode: n.refCode?.trim() || n.title?.trim() || "",
pickOrderId: n.refId ?? null,
consoCode: n.consoCode?.trim() || "",
materialItemCode: n.traceItemCode?.trim() || "",
materialLotNo: n.traceLotNo?.trim() || "",
qty: n.qty ?? 0,
uom: n.uom?.trim() || "",
pickedAt: n.timestamp,
bomProcessId: n.bomProcessId ?? null,
assignedStepName: n.assignedStepName?.trim() || "",
targetDate: n.docLinkTargetDate?.trim() || undefined,
}));
}

return (fallbackInputs ?? []).map((m, i) => ({
id: `pick-api-${i}-${m.pickOrderCode}-${m.materialLotNo}`,
pickOrderCode: m.pickOrderCode,
pickOrderId: m.pickOrderId,
consoCode: m.consoCode,
materialItemCode: m.materialItemCode,
materialLotNo: m.materialLotNo,
qty: m.materialQty,
uom: m.materialUom ?? "",
pickedAt: m.pickedAt,
bomProcessId: m.bomProcessId ?? null,
assignedStepName: m.assignedStepName ?? "",
targetDate: undefined,
}));
};

export const deriveProductionRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
fallback: ItemLotTraceResponse["productionSteps"],
): TraceProductionRow[] => {
const steps = nodes.filter((n) => n.kind === "PRODUCTION_STEP");
if (steps.length > 0) {
return steps.map((n) => ({
id: n.id,
stepName: n.title?.trim() || detailValue(n, "Step"),
description: detailValue(n, "Remarks"),
equipmentCode: "",
equipmentName: detailValue(n, "Equipment"),
operatorName: detailValue(n, "Handler"),
startTime: null,
endTime: n.timestamp,
outputQty: n.qty ?? 0,
scrapQty: 0,
defectQty: 0,
status: detailValue(n, "Status"),
seqNo: n.bomProcessSeqNo ?? null,
bomProcessId: n.bomProcessId ?? null,
traceLotNo: n.traceLotNo ?? null,
}));
}

return fallback.map((s) => ({
id: `prod-api-${s.processLineId}`,
stepName: s.stepName,
description: s.description,
equipmentCode: s.equipmentCode,
equipmentName: s.equipmentName,
operatorName: s.operatorName,
startTime: s.startTime,
endTime: s.endTime,
outputQty: s.outputQty,
scrapQty: s.scrapQty,
defectQty: s.defectQty,
status: s.status,
seqNo: s.seqNo,
bomProcessId: s.bomProcessId ?? null,
traceLotNo: null,
}));
};

export type TracePresentationRows = {
movements: TraceMovementRow[];
joPicks: TraceJoPickRow[];
production: TraceProductionRow[];
};

export const buildTracePresentationRows = (
nodes: TraceGraphLayoutNode[],
data: ItemLotTraceResponse,
): TracePresentationRows => ({
movements: deriveMovementRowsFromGraph(nodes, data),
joPicks: deriveJoPickRowsFromGraph(nodes, data.joPrelude?.materialInputs),
production: deriveProductionRowsFromGraph(nodes, data.productionSteps),
});

+ 174
- 0
src/components/ItemTracing/tracePutawayUtils.ts Ver arquivo

@@ -0,0 +1,174 @@
import { TraceLabelTranslator } from "./traceLabelUtils";

/** Matches PO receiving UI: only `completed` means 已上架. */
const COMPLETED_PUTAWAY_STATUSES = new Set(["completed", "complete"]);

export const normRefType = (refType: string | null | undefined): string =>
(refType ?? "").trim().toUpperCase();

const normWhCode = (code: string | null | undefined): string =>
(code ?? "").trim().toUpperCase();

/** Match exact warehouse codes or floor vs bin (e.g. 4F ↔ 4F-W401). */
export const warehouseCodesMatch = (
a: string | null | undefined,
b: string | null | undefined,
): boolean => {
const x = normWhCode(a);
const y = normWhCode(b);
if (!x || !y) return false;
if (x === y) return true;
return x.startsWith(`${y}-`) || y.startsWith(`${x}-`);
};

/** Transfer destination stock-in is shown on the TRANSFER node, not as a separate putaway card. */
export const isTransferInboundPutaway = (refType: string | null | undefined): boolean =>
normRefType(refType) === "TRANSFER";

export type TraceTransferMatchInput = {
fromWarehouse?: string | null;
toWarehouse?: string | null;
qty?: number | null;
transferCode?: string | null;
timestamp?: string | null;
};

/**
* Putaway refCode is often SI-* while transferCode is TR-*; match by code, then to-warehouse + qty/time.
*/
export const matchTransferForInboundPutaway = (
putaway: {
warehouseCode?: string | null;
qty?: number | null;
refCode?: string | null;
timestamp?: string | null;
},
transfers: TraceTransferMatchInput[],
): TraceTransferMatchInput | undefined => {
if (!transfers.length) return undefined;
const ref = (putaway.refCode ?? "").trim();
if (ref) {
const byCode = transfers.find((tr) => (tr.transferCode ?? "").trim() === ref);
if (byCode) return byCode;
}

const toMatched = transfers.filter((tr) =>
warehouseCodesMatch(tr.toWarehouse, putaway.warehouseCode),
);
if (!toMatched.length) return undefined;
if (toMatched.length === 1) return toMatched[0];

const putawayQty = putaway.qty;
const qtyMatched =
putawayQty != null
? toMatched.filter((tr) => tr.qty != null && Number(tr.qty) === Number(putawayQty))
: [];
const pool = qtyMatched.length > 0 ? qtyMatched : toMatched;

const putawayTs = Date.parse(putaway.timestamp ?? "");
if (!Number.isNaN(putawayTs)) {
const ranked = pool
.map((tr) => {
const ts = Date.parse(tr.timestamp ?? "");
return { tr, dist: Number.isNaN(ts) ? Number.POSITIVE_INFINITY : Math.abs(ts - putawayTs) };
})
.sort((a, b) => a.dist - b.dist);
if (ranked[0] && Number.isFinite(ranked[0].dist)) return ranked[0].tr;
}
return pool[0];
};

/**
* Emit a TRANSFER card for each transfer on the primary lot.
* Location-block scopes skip transfers to avoid duplicating the same TR-* rows.
*/
export const shouldEmitTransferForScope = (locationBlockKeys?: boolean): boolean =>
!locationBlockKeys;

/**
* Transfer inbound putaways are represented by TRANSFER cards (one per TR-*).
* Keep 轉倉入庫 putaway cards off so multi-transfer lots do not collapse to one inbound.
*/
export const shouldShowTransferInboundPutawayCard = (
_scopeWarehouse?: string | null,
_putawayWarehouse?: string | null,
_transfers?: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>,
_mergedMultiLocation?: boolean,
): boolean => false;

/**
* In a merged multi-warehouse graph, skip 已用完 when this location was emptied by transfer
* to another location still shown on the same graph. Remaining DO picks belong to the destination.
*/
export const shouldEmitDepletedForScope = (
availableQty: number,
scopeWarehouse: string | null | undefined,
transfers: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>,
mergedMultiLocation: boolean,
): boolean => {
if (availableQty > 0) return false;
if (!mergedMultiLocation) return true;
const scope = scopeWarehouse?.trim();
if (!scope) return true;
const emptiedByTransfer = transfers.some((tr) =>
warehouseCodesMatch(scope, tr.fromWarehouse),
);
return !emptiedByTransfer;
};

export type PutawayPresentationLabels = {
nodePutaway: string;
nodePutawayTransfer: string;
putawayTransferDetail: string;
};

export type PutawayPresentation = {
/** Chip on flow card (top badge). */
chipLabel: string;
/** Status row in node detail panel. */
statusDetail: string;
/** Card title (replaces generic「上架」for transfer inbound). */
title: string;
/** Type row in node detail panel. */
detailType: string;
};

export const isPendingPutawayOriginStatus = (status: string | null | undefined): boolean => {
const normalized = (status ?? "").trim().toLowerCase();
if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) return false;
return true;
};

export const resolvePutawayStatusLabel = (
tr: TraceLabelTranslator,
status: string | null | undefined,
): string => {
const normalized = (status ?? "").trim().toLowerCase();
if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) {
return tr.putawayShelfStatus("completed");
}
return tr.putawayShelfStatus("pending");
};

export const resolvePutawayPresentation = (
tr: TraceLabelTranslator,
labels: PutawayPresentationLabels,
status: string | null | undefined,
refType: string | null | undefined,
): PutawayPresentation => {
if (normRefType(refType) === "TRANSFER") {
return {
chipLabel: labels.nodePutawayTransfer,
statusDetail: labels.putawayTransferDetail,
title: labels.nodePutawayTransfer,
detailType: labels.nodePutawayTransfer,
};
}
const shelf = resolvePutawayStatusLabel(tr, status);
return {
chipLabel: shelf,
statusDetail: shelf,
title: labels.nodePutaway,
detailType: labels.nodePutaway,
};
};

+ 38
- 0
src/components/ItemTracing/traceQtyUtils.ts Ver arquivo

@@ -0,0 +1,38 @@
export const formatNum = (n: number) =>
Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 4 }) : "—";

/** Packaging / compound stock unit (e.g. 1包X25千克) — not a simple unit like 斤 or kg. */
export const isPackagingUom = (uom?: string | null): boolean => {
const unit = uom?.trim();
if (!unit) return false;
if (/^\d/.test(unit)) return true;
if (/[xX×]/.test(unit) && /\d/.test(unit)) return true;
return false;
};

/** Join qty with UOM; packaging units use parentheses to avoid "6 1包…" ambiguity. */
export const joinQtyUom = (num: string, unit: string): string =>
isPackagingUom(unit) ? `${num}(${unit})` : `${num} ${unit}`;

/** Format a quantity with its unit when available. */
export const formatQty = (qty: number | null | undefined, uom?: string | null): string => {
if (qty == null || !Number.isFinite(Number(qty))) return "—";
const num = formatNum(Number(qty));
const unit = uom?.trim();
return unit ? joinQtyUom(num, unit) : num;
};

/** Signed variance for stock adjustments: IN → +qty, OUT → −qty. */
export const formatSignedQty = (
qty: number | null | undefined,
direction: string | null | undefined,
uom?: string | null,
): string => {
if (qty == null || !Number.isFinite(Number(qty))) return "—";
const abs = Math.abs(Number(qty));
const sign = (direction ?? "").trim().toUpperCase() === "OUT" ? "-" : "+";
const num = formatNum(abs);
const unit = uom?.trim();
const signed = `${sign}${num}`;
return unit ? joinQtyUom(signed, unit) : signed;
};

+ 176
- 0
src/components/ItemTracing/traceStockTakeUtils.ts Ver arquivo

@@ -0,0 +1,176 @@
import type { ItemLotTraceStockTakeEvent, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing";

export const formatStockTakeRoundLabel = (event: ItemLotTraceStockTakeEvent): string => {
const name = event.stockTakeRoundName?.trim();
if (name) return name;
if (event.stockTakeRoundId != null) return `#${event.stockTakeRoundId}`;
return "";
};

export const stockTakeEventSubtitle = (event: ItemLotTraceStockTakeEvent): string => {
const parts = [
formatStockTakeRoundLabel(event),
event.stockTakeSection?.trim(),
event.warehouseCode?.trim(),
event.lotNo?.trim() || undefined,
].filter(Boolean);
return parts.length > 0 ? parts.join(" · ") : event.stockTakeCode || "—";
};

/** Book (system) qty at count time — prefer record snapshot over line initialQty. */
export const resolveStockTakeBookQty = (
detail: ItemLotTraceStockTakeRecordDetail | null | undefined,
fallbackBeforeQty?: number | null,
): number | null => {
if (detail?.bookQty != null && !Number.isNaN(Number(detail.bookQty))) {
return Number(detail.bookQty);
}
if (fallbackBeforeQty != null && !Number.isNaN(Number(fallbackBeforeQty))) {
return Number(fallbackBeforeQty);
}
return null;
};

/**
* FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20
* Accepted physical qty used for variance / posting decision.
* Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty.
* Incomplete rounds (no count yet) must not surface as 0 via COALESCE(finalQty, 0).
*/
export const resolveStockTakeAcceptedQty = (
detail: ItemLotTraceStockTakeRecordDetail | null | undefined,
fallbackAfterQty?: number | null,
): number | null => {
const pick = (n: number | null | undefined) =>
n != null && !Number.isNaN(Number(n)) ? Number(n) : null;

if (detail) {
switch (detail.lastSelect) {
case 3: {
const q = pick(detail.approverQty);
if (q != null) return q;
break;
}
case 2: {
const q = pick(detail.pickerSecondQty);
if (q != null) return q;
break;
}
case 1: {
const q = pick(detail.pickerFirstQty);
if (q != null) return q;
break;
}
default:
break;
}
const fromApprover = pick(detail.approverQty);
if (fromApprover != null) return fromApprover;
const fromSecond = pick(detail.pickerSecondQty);
if (fromSecond != null) return fromSecond;
const fromFirst = pick(detail.pickerFirstQty);
if (fromFirst != null) return fromFirst;

const status = (detail.recordStatus ?? "").trim().toUpperCase();
const accepted =
status === "ACCEPTED" || status === "COMPLETED" || status === "COMPLETE";
if (!accepted) {
return null;
}
}
if (fallbackAfterQty != null && !Number.isNaN(Number(fallbackAfterQty))) {
return Number(fallbackAfterQty);
}
return null;
};

export interface StockTakeLifecycleStage {
key: string;
label: string;
qty?: number | null;
badQty?: number | null;
handler?: string | null;
timestamp?: string | null;
isActive: boolean;
}

/** Build ordered lifecycle stages from stocktake record detail. */
export const buildStockTakeLifecycleStages = (
detail: ItemLotTraceStockTakeRecordDetail | null | undefined,
): StockTakeLifecycleStage[] => {
if (!detail) return [];
const stages: StockTakeLifecycleStage[] = [];

// Stage 1: Round created — show book (帳面) qty for context
stages.push({
key: "round-created",
label: "stockTakeStageCreated",
qty: detail.bookQty,
handler: null,
timestamp: detail.stockTakeStartTime ?? null,
isActive: true,
});

// Stage 2: Picker first count
const hasFirstCount =
detail.pickerFirstQty != null ||
detail.pickerFirstBadQty != null ||
Boolean(detail.stockTakerName?.trim());
stages.push({
key: "first-count",
label: "stockTakeStageFirstCount",
qty: detail.pickerFirstQty,
badQty: detail.pickerFirstBadQty,
handler: detail.stockTakerName,
timestamp: detail.stockTakeStartTime ?? null,
isActive: hasFirstCount,
});

// Stage 3: Picker second count (re-count) — only if present
const hasSecondCount =
detail.pickerSecondQty != null || detail.pickerSecondBadQty != null;
if (hasSecondCount) {
stages.push({
key: "second-count",
label: "stockTakeStageSecondCount",
qty: detail.pickerSecondQty,
badQty: detail.pickerSecondBadQty,
handler: detail.stockTakerName,
timestamp: detail.stockTakeEndTime ?? null,
isActive: true,
});
}

// Stage 4: Approver manual input — only when lastSelect === 3 (admin entered own count)
const hasApproverCount =
detail.approverQty != null || detail.approverBadQty != null;
if (detail.lastSelect === 3 && hasApproverCount) {
stages.push({
key: "approver-count",
label: "stockTakeStageApproverCount",
qty: detail.approverQty,
badQty: detail.approverBadQty,
handler: detail.approverName,
timestamp: detail.approverTime ?? null,
isActive: true,
});
}

// Stage 5: Accepted — show accepted physical qty; variance via badQty for secondary line
const isAccepted =
detail.recordStatus?.toUpperCase() === "ACCEPTED" ||
detail.recordStatus?.toUpperCase() === "COMPLETED" ||
detail.recordStatus?.toUpperCase() === "COMPLETE";
const acceptedQty = resolveStockTakeAcceptedQty(detail, null);
stages.push({
key: "accepted",
label: isAccepted ? "stockTakeStageAccepted" : "stockTakeStagePending",
qty: acceptedQty,
badQty: detail.varianceQty,
handler: detail.approverName,
timestamp: detail.approverTime ?? detail.stockTakeEndTime ?? null,
isActive: isAccepted || Boolean(detail.approverName?.trim()),
});

return stages;
};

+ 207
- 111
src/components/JoWorkbench/JoPickOrderList.tsx Ver arquivo

@@ -1,11 +1,9 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
CardActions,
Stack,
Typography,
Chip,
@@ -26,6 +24,17 @@ interface Props {
printerCombo: PrinterCombo[];
}

const chipSx = {
flexShrink: 0,
height: 28,
borderRadius: "14px",
"& .MuiChip-label": {
typography: "body2",
px: 1.25,
lineHeight: 1.2,
},
} as const;

/** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */
const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
const { t } = useTranslation(["common", "jo"]);
@@ -44,10 +53,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
{ label: t("Item Name"), paramName: "itemName", type: "text" },
{
label: t("Job Order Type"),
paramName: "BOM Description",
paramName: "bomDescription",
type: "select-labelled",
options: [
//{ label: t("All"), value: "All" },
{ label: t("FG"), value: "FG" },
{ label: t("WIP"), value: "WIP" },
],
@@ -58,7 +66,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "bomType",
type: "select-labelled",
options: [
//{ label: t("All"), value: "All" },
{ label: t("Drink"), value: "drink" },
{ label: t("Powder Mixture"), value: "Powder_Mixture" },
{ label: t("Other"), value: "other" },
@@ -69,7 +76,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "floor",
type: "select-labelled",
options: [
//{ label: t("All"), value: "ALL" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
@@ -179,8 +185,8 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("Total pick orders")}: {pickOrders.length}
</Typography>
<Grid container spacing={2}>
<Grid container spacing={2} alignItems="stretch">
{pickOrders.map((pickOrder) => {
const status = String(pickOrder.jobOrderStatus || "");
const statusLower = status.toLowerCase();
@@ -190,126 +196,216 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
: statusLower === "pending" || statusLower === "processing"
? "primary"
: "default";
const finishedCount = pickOrder.finishedPickOLineCount ?? 0;
const bomDescription = pickOrder.bomDescription
? String(pickOrder.bomDescription).trim()
: "";
const bomType = pickOrder.bomType
? String(pickOrder.bomType).trim()
: "";

return (
<Grid key={pickOrder.id} item xs={12} sm={6} md={4}>
<Grid
key={pickOrder.id}
item
xs={12}
sm={6}
md={4}
sx={{ display: "flex" }}
>
<Card
sx={{
minHeight: 180,
maxHeight: 280,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
boxShadow: "none",
}}
>
<CardContent
<Box
sx={{
pb: 1,
p: 2,
flexGrow: 1,
overflow: "auto",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1">
{t("Job Order")}: {pickOrder.jobOrderCode || "-"}
</Typography>
</Box>
<Chip size="small" label={t(status)} color={statusColor as any} />
</Stack>
<Typography variant="body2" color="text.secondary">
{t("Lot No")}: {pickOrder.lotNo || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Pick Order")}: {pickOrder.pickOrderCode || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Item Name")}: {pickOrder.itemName}
{pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName})
<Typography
variant="body1"
color="text.primary"
fontWeight={600}
title={
[pickOrder.itemCode, pickOrder.itemName]
.filter(Boolean)
.join(" ") || undefined
}
sx={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
lineHeight: 1.35,
}}
>
{[pickOrder.itemCode, pickOrder.itemName]
.filter(Boolean)
.join(" ") || "-"}
</Typography>
{selectedFloor === "ALL" ? (
<>
{pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))}
{!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>

<Stack
direction="row"
alignItems="center"
spacing={0.75}
flexWrap="nowrap"
useFlexGap
sx={{
mt: 0.75,
minWidth: 0,
overflowX: "auto",
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{bomDescription ? (
<Chip
size="small"
label={t(bomDescription)}
variant="outlined"
sx={chipSx}
/>
) : null}
{bomType ? (
<Chip
size="small"
label={t(bomType)}
color="info"
variant="outlined"
sx={chipSx}
/>
) : null}
<Chip
size="small"
label={t(status)}
color={statusColor as any}
sx={chipSx}
/>
</Stack>

<Stack
spacing={0.35}
sx={{ mt: 0.75, color: "text.secondary" }}
>
<Typography variant="body2">
{t("Pick Order")}: {pickOrder.pickOrderCode || "-"}
</Typography>
<Typography variant="body2">
{t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName})
</Typography>

<Box>
{selectedFloor === "ALL" ? (
<>
{pickOrder.floorPickCounts?.map(
({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
),
)}
{!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}:{" "}
{pickOrder.noLotPickCount.finishedCount}/
{pickOrder.noLotPickCount.totalCount}
</Typography>
)}
</>
) : selectedFloor === "NO_LOT" ? (
!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}:{" "}
{pickOrder.noLotPickCount.finishedCount}/
{pickOrder.noLotPickCount.totalCount}
</Typography>
)
) : (
pickOrder.floorPickCounts
?.filter((c) => c.floor === selectedFloor)
.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))
)}
</>
) : selectedFloor === "NO_LOT" ? (
!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>
)
) : (
pickOrder.floorPickCounts
?.filter((c) => c.floor === selectedFloor)
.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Box>

{typeof pickOrder.suggestedFailCount === "number" &&
pickOrder.suggestedFailCount > 0 && (
<Typography variant="body2" color="error">
{t("Suggested Fail")}: {pickOrder.suggestedFailCount}
</Typography>
))
)}
{typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && (
<Typography variant="body2" color="error" sx={{ mt: 0.5 }}>
{t("Suggested Fail")}: {pickOrder.suggestedFailCount}
</Typography>
)}
{statusLower !== "pending" && finishedCount > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="body2" fontWeight={600}>
)}

{statusLower !== "pending" && finishedCount > 0 && (
<Typography variant="body2" fontWeight={600} color="text.primary">
{t("Finished lines")}: {finishedCount}
</Typography>
</Box>
)}
</CardContent>
<CardActions sx={{ pt: 0.5 }}>
<Button
variant="contained"
size="small"
onClick={() => {
setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined);
setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined);
}}
)}
</Stack>

<Stack sx={{ mt: "auto", pt: 1.5 }}>
<Button
variant="contained"
size="small"
sx={{ alignSelf: "flex-start" }}
onClick={() => {
setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined);
setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined);
}}
>
{t("View Details")}
</Button>
</Stack>

<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1.25 }}
>
{t("View Details")}
</Button>
<Box sx={{ flex: 1 }} />
</CardActions>
{pickOrder.jobOrderCode || "-"}
{" · "}
{t("Lot No")}: {pickOrder.lotNo || "-"}
</Typography>
</Box>
</Card>
</Grid>
);


+ 85
- 80
src/components/JoWorkbench/newJobPickExecution.tsx Ver arquivo

@@ -618,6 +618,7 @@ const QrCodeModal: React.FC<{
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => {
const workbenchMode = true;
const { t } = useTranslation("jo");
@@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId;

let switchedToUnavailable = false;
// noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo
if (!originalSuggestedPickLotId) {
if (!selectedLotForQr?.stockOutLineId) {
throw new Error("Missing stockOutLineId for noLot line");
}
console.log(
"🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...",
);
const res = await updateStockOutLineStatusByQRCodeAndLotNo({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
inventoryLotNo: effectiveScannedLot.lotNo || "",
stockInLineId: effectiveScannedLot?.stockInLineId ?? null,
stockOutLineId: selectedLotForQr.stockOutLineId,
itemId: selectedLotForQr.itemId,
status: "checked",
});
console.log(
"✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:",
res,
);
switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE";
const ok =
res?.code === "checked" ||
res?.code === "SUCCESS" ||
switchedToUnavailable;
if (!ok) {
const errMsg =
res?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: res?.message ||
tPick(
"Lot switch failed; pick line was not marked as checked.",
);
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
} else {
// Call confirmLotSubstitution to update the suggested lot
console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution...");
const substitutionResult = await confirmLotSubstitution({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
stockOutLineId: selectedLotForQr.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: effectiveScannedLot.lotNo || "",
// ✅ required by LotSubstitutionConfirmRequest
newStockInLineId: effectiveScannedLot?.stockInLineId ?? null,
});

console.log(
"✅ [LOT CONFIRM] Lot substitution result:",
substitutionResult,
);
// Workbench no-hold: skip classic bind/switch (holdQty). Switch + pick via scan-pick only.
// Non-workbench: keep confirmLotSubstitution / QR bind (moves hold).
if (!workbenchMode) {
// noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo
if (!originalSuggestedPickLotId) {
if (!selectedLotForQr?.stockOutLineId) {
throw new Error("Missing stockOutLineId for noLot line");
}
console.log(
"🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...",
);
const res = await updateStockOutLineStatusByQRCodeAndLotNo({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
inventoryLotNo: effectiveScannedLot.lotNo || "",
stockInLineId: effectiveScannedLot?.stockInLineId ?? null,
stockOutLineId: selectedLotForQr.stockOutLineId,
itemId: selectedLotForQr.itemId,
status: "checked",
});
console.log(
"✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:",
res,
);
switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE";
const ok =
res?.code === "checked" ||
res?.code === "SUCCESS" ||
switchedToUnavailable;
if (!ok) {
const errMsg =
res?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: res?.message ||
tPick(
"Lot switch failed; pick line was not marked as checked.",
);
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
} else {
// Call confirmLotSubstitution to update the suggested lot
console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution...");
const substitutionResult = await confirmLotSubstitution({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
stockOutLineId: selectedLotForQr.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: effectiveScannedLot.lotNo || "",
// ✅ required by LotSubstitutionConfirmRequest
newStockInLineId: effectiveScannedLot?.stockInLineId ?? null,
});

// ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked.
// Keep modal open so user can cancel/rescan.
switchedToUnavailable =
substitutionResult?.code === "SUCCESS_UNAVAILABLE" ||
substitutionResult?.code === "BOUND_UNAVAILABLE";
if (
!substitutionResult ||
(substitutionResult.code !== "SUCCESS" && !switchedToUnavailable)
) {
console.error(
"❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.",
console.log(
"✅ [LOT CONFIRM] Lot substitution result:",
substitutionResult,
);
const errMsg =
substitutionResult?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${
effectiveScannedLot?.stockInLineId ?? ""
} 不存在或无法匹配`;
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;

// ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked.
// Keep modal open so user can cancel/rescan.
switchedToUnavailable =
substitutionResult?.code === "SUCCESS_UNAVAILABLE" ||
substitutionResult?.code === "BOUND_UNAVAILABLE";
if (
!substitutionResult ||
(substitutionResult.code !== "SUCCESS" && !switchedToUnavailable)
) {
console.error(
"❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.",
);
const errMsg =
substitutionResult?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${
effectiveScannedLot?.stockInLineId ?? ""
} 不存在或无法匹配`;
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
}
}



+ 26
- 1
src/components/Jodetail/JodetailSearch.tsx Ver arquivo

@@ -1,6 +1,7 @@
"use client";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import {
@@ -50,8 +51,25 @@ type SearchQuery = Partial<

type SearchParamNames = keyof SearchQuery;

/** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */
const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
const { t } = useTranslation("jo");
const searchParams = useSearchParams();
const urlTabRaw = searchParams.get("tab");
const urlPickOrderCode = searchParams.get("pickOrderCode")?.trim() || undefined;
const urlTargetDateRaw = searchParams.get("targetDate")?.trim() || undefined;
const urlTargetDate = (() => {
if (!urlTargetDateRaw) return undefined;
const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : undefined;
})();
/** Item Tracing deep-link only; other navigations must not set this. */
const urlOpenDetail = searchParams.get("openDetail") === "1";
const urlTabIndex = useMemo(() => {
if (urlTabRaw == null) return undefined;
const n = parseInt(urlTabRaw, 10);
return !Number.isNaN(n) && n >= 0 && n <= 3 ? n : undefined;
}, [urlTabRaw]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;

@@ -61,7 +79,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
//const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders);
const [filterArgs, setFilterArgs] = useState<Record<string, any>>({});
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [tabIndex, setTabIndex] = useState(0);
const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0);
const [totalCount, setTotalCount] = useState<number>();
const [isAssigning, setIsAssigning] = useState(false);
const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]);
@@ -70,6 +88,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
const [hasDataTab0, setHasDataTab0] = useState(false);
const [hasDataTab1, setHasDataTab1] = useState(false);
const hasAnyAssignedData = hasDataTab0 || hasDataTab1;

useEffect(() => {
if (urlTabIndex != null) setTabIndex(urlTabIndex);
}, [urlTabIndex]);
// Add printer selection state
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
@@ -492,6 +514,9 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
printerCombo={printerCombo}
selectedPrinter={selectedPrinter}
printQty={printQty}
initialPickOrderCode={urlPickOrderCode}
initialTargetDate={urlTargetDate}
openDetail={urlOpenDetail}
/>
)}
{tabIndex === 2 && <MaterialPickStatusTable />}


+ 42
- 5
src/components/Jodetail/completeJobOrderRecord.tsx Ver arquivo

@@ -58,6 +58,10 @@ interface Props {
printerCombo: PrinterCombo[];
selectedPrinter?: PrinterCombo | null;
printQty?: number;
initialPickOrderCode?: string;
initialTargetDate?: string;
/** When true (Item Tracing `openDetail=1`), auto-open matching pick-order detail. */
openDetail?: boolean;
}

// 修改:已完成的 Job Order Pick Order 接口
@@ -112,11 +116,15 @@ interface LotDetail {
match_status: string | null;
}

const CompleteJobOrderRecord: React.FC<Props> = ({
/** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */
const CompleteJobOrderRecord: React.FC<Props> = ({
filterArgs,
printerCombo,
selectedPrinter: selectedPrinterProp,
printQty: printQtyProp
printQty: printQtyProp,
initialPickOrderCode,
initialTargetDate,
openDetail = false,
}) => {
const { t } = useTranslation("jo");
const router = useRouter();
@@ -135,15 +143,23 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
const [detailLotDataLoading, setDetailLotDataLoading] = useState(false);
// 修改:搜索状态
const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => ({
completedDate: dayjs().format("YYYY-MM-DD"),
}));
const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => {
const raw = initialTargetDate?.trim() || "";
const match = raw.match(/(\d{4}-\d{2}-\d{2})/);
return {
completedDate: match?.[1] || dayjs().format("YYYY-MM-DD"),
...(initialPickOrderCode?.trim()
? { pickOrderCode: initialPickOrderCode.trim() }
: {}),
};
});
const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]);
// Use props with fallback
const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null);
const printQty = printQtyProp ?? 1;
const pickRecordPrintInFlightRef = useRef(false);
const initialDetailOpenedRef = useRef(false);

// 修改:分页状态
const [paginationController, setPaginationController] = useState({
@@ -370,6 +386,27 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
}, [fetchLotDetailsData]);

useEffect(() => {
if (!openDetail) return;
if (initialDetailOpenedRef.current || completedJobOrderPickOrdersLoading) return;
const code = initialPickOrderCode?.trim();
if (!code) return;
const match = completedJobOrderPickOrders.find(
(row) =>
row.pickOrderCode?.trim() === code ||
row.pickOrderConsoCode?.trim() === code,
);
if (!match) return;
initialDetailOpenedRef.current = true;
void handleDetailClick(match);
}, [
openDetail,
completedJobOrderPickOrders,
completedJobOrderPickOrdersLoading,
initialPickOrderCode,
handleDetailClick,
]);

// 修改:返回列表视图
const handleBackToList = useCallback(() => {
setShowDetailView(false);


+ 8
- 1
src/components/NavigationContent/NavigationContent.tsx Ver arquivo

@@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => {
icon: <Storefront />,
labelKey: "nav.storeManagement",
path: "",
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ITEM_TRACING, AUTH.ADMIN],
children: [
{
id: "nav.store.purchaseOrder",
@@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/inventory",
},
{
id: "nav.store.itemTracing",
icon: <QrCodeIcon />,
labelKey: "nav.store.itemTracing",
requiredAbility: [AUTH.ITEM_TRACING],
path: "/itemTracing",
},
{
id: "nav.store.stockTake",
icon: <AssignmentTurnedIn />,


+ 8
- 3
src/components/PickOrderSearch/AssignAndRelease.tsx Ver arquivo

@@ -50,7 +50,9 @@ interface ItemRow {
id: string;
pickOrderId: number;
pickOrderCode: string;
pickOrderLineId?: number;
itemId: number;
uomId?: number;
itemCode: string;
itemName: string;
requiredQty: number;
@@ -85,6 +87,7 @@ const style = {
width: { xs: "100%", sm: "100%", md: "100%" },
};

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { setIsUploading } = useUploadContext();
@@ -167,11 +170,13 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
// const filteredRecords = res.records.filter(
// (item: any) => (item.status || "").toLowerCase() === "pending"
//);
const itemRows: ItemRow[] = res.records.map((item: any) => ({
id: item.id,
const itemRows: ItemRow[] = res.records.map((item: any, index: number) => ({
id: item.id ?? `${item.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`,
pickOrderId: item.pickOrderId,
pickOrderCode: item.pickOrderCode,
pickOrderLineId: item.pickOrderLineId,
itemId: item.itemId,
uomId: item.uomId,
itemCode: item.itemCode,
itemName: item.itemName,
requiredQty: item.requiredQty,
@@ -493,7 +498,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
) : (
groupedItems.map((group) => (
group.items.map((item, index) => (
<TableRow key={item.id}>
<TableRow key={item.id || `${group.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`}>
{/* Checkbox - 只在第一个项目显示,按 pick order 选择 */}
<TableCell>
{index === 0 ? (


+ 12
- 13
src/components/PickOrderSearch/CreatedItemsTable.tsx Ver arquivo

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react';
import {
Box,
Typography,
Table,
TableBody,
@@ -43,15 +42,16 @@ interface Group {
interface CreatedItemsTableProps {
items: CreatedItem[];
groups: Group[];
onItemSelect: (itemId: number, checked: boolean) => void;
onQtyChange: (itemId: number, qty: number) => void;
onGroupChange: (itemId: number, groupId: string) => void;
onItemSelect: (itemId: number, checked: boolean, uomId?: number) => void;
onQtyChange: (itemId: number, qty: number, uomId?: number) => void;
onGroupChange: (itemId: number, groupId: string, uomId?: number) => void;
pageNum: number;
pageSize: number;
onPageChange: (event: unknown, newPage: number) => void;
onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
items,
groups,
@@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
}) => {
const { t } = useTranslation("pickOrder");

// Calculate pagination
const startIndex = (pageNum - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedItems = items.slice(startIndex, endIndex);

const handleQtyChange = useCallback((itemId: number, value: string) => {
const handleQtyChange = useCallback((itemId: number, uomId: number, value: string) => {
const numValue = Number(value);
if (!isNaN(numValue) && numValue >= 1) {
onQtyChange(itemId, numValue);
onQtyChange(itemId, numValue, uomId);
}
}, [onQtyChange]);

@@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
</TableRow>
) : (
paginatedItems.map((item) => (
<TableRow key={item.itemId}>
<TableRow key={`${item.itemId}_${item.uomId}`}>
<TableCell padding="checkbox">
<Checkbox
checked={item.isSelected}
onChange={(e) => onItemSelect(item.itemId, e.target.checked)}
onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)}
/>
</TableCell>
<TableCell>
@@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => onGroupChange(item.itemId, e.target.value)}
onChange={(e) => onGroupChange(item.itemId, e.target.value, item.uomId)}
displayEmpty
>
<MenuItem value="">
@@ -157,14 +156,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{item.uomDesc}</Typography>
<Typography variant="body2">{item.uomDesc || item.uom || "-"}</Typography>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => handleQtyChange(item.itemId, e.target.value)}
onChange={(e) => handleQtyChange(item.itemId, item.uomId, e.target.value)}
inputProps={{
min: 1,
step: 1,
@@ -208,4 +207,4 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
);
};

export default CreatedItemsTable;
export default CreatedItemsTable;

+ 55
- 70
src/components/PickOrderSearch/SearchResultsTable.tsx Ver arquivo

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react';
import {
Box,
Typography,
Table,
TableBody,
@@ -22,8 +21,12 @@ import dayjs from 'dayjs';

interface SearchItemWithQty {
id: number;
/** Unique search-row key: itemId + uomId */
rowKey: string;
label: string;
qty: number | null;
uomId?: number;
uom?: string;
currentStockBalance?: number;
uomDesc?: string;
targetDate?: string | null;
@@ -40,17 +43,18 @@ interface SearchResultsTableProps {
items: SearchItemWithQty[];
selectedItemIds: (string | number)[];
groups: Group[];
onItemSelect: (itemId: number, checked: boolean) => void;
onQtyChange: (itemId: number, qty: number | null) => void;
onQtyBlur: (itemId: number) => void;
onGroupChange: (itemId: number, groupId: string) => void;
isItemInCreated: (itemId: number) => boolean;
onItemSelect: (rowKey: string, checked: boolean) => void;
onQtyChange: (rowKey: string, qty: number | null) => void;
onQtyBlur: (rowKey: string) => void;
onGroupChange: (rowKey: string, groupId: string) => void;
isItemInCreated: (itemId: number, uomId?: number) => boolean;
pageNum: number;
pageSize: number;
onPageChange: (event: unknown, newPage: number) => void;
onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
items,
selectedItemIds,
@@ -67,16 +71,14 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
}) => {
const { t } = useTranslation("pickOrder");

// Calculate pagination
const startIndex = (pageNum - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedResults = items.slice(startIndex, endIndex);

const handleQtyChange = useCallback((itemId: number, value: string) => {
// Only allow numbers
const handleQtyChange = useCallback((rowKey: string, value: string) => {
if (value === "" || /^\d*\.?\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(itemId, numValue);
onQtyChange(rowKey, numValue);
}
}, [onQtyChange]);

@@ -119,50 +121,45 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
</TableCell>
</TableRow>
) : (
paginatedResults.map((item) => (
<TableRow key={item.id}>
paginatedResults.map((item) => {
const rowKey = item.rowKey || `${item.id}_${item.uomId || 0}`;
const inCreated = isItemInCreated(item.id, item.uomId);
return (
<TableRow key={rowKey}>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItemIds.includes(item.id)}
onChange={(e) => onItemSelect(item.id, e.target.checked)}
disabled={isItemInCreated(item.id)}
checked={selectedItemIds.includes(rowKey) || inCreated}
onChange={(e) => onItemSelect(rowKey, e.target.checked)}
disabled={inCreated}
/>
</TableCell>
{/* Item */}
<TableCell>
<Box>
<Typography variant="body2">
{item.label.split(' - ')[1] || item.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{item.label.split(' - ')[0] || ''}
</Typography>
</Box>
<Typography variant="body2">
{item.label.split(' - ')[1] || item.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{item.label.split(' - ')[0] || ''}
</Typography>
</TableCell>
{/* Group */}
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => onGroupChange(item.id, e.target.value)}
onChange={(e) => onGroupChange(rowKey, e.target.value)}
displayEmpty
disabled={isItemInCreated(item.id)}
>
disabled={inCreated}
>
<MenuItem value="">
<em>{t("No Group")}</em>
<em>{t("No Group")}</em>
</MenuItem>
{groups.map((group) => (
<MenuItem key={group.id} value={group.id.toString()}>
<MenuItem key={group.id} value={group.id.toString()}>
{group.name}
</MenuItem>
</MenuItem>
))}
</Select>
</Select>
</FormControl>
</TableCell>
{/* Current Stock */}
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
@@ -172,54 +169,42 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
{item.currentStockBalance?.toLocaleString()||0}
</Typography>
</TableCell>
{/* Stock Unit */}
<TableCell align="right">
<Typography variant="body2">
{item.uomDesc || "-"}
{item.uomDesc || item.uom || "-"}
</Typography>
</TableCell>
<TableCell align="right">
{/* Order Quantity */}

<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
const value = e.target.value;
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(item.id, numValue);
}
}}
onBlur={() => {
// Trigger auto-add check when user finishes input (clicks elsewhere)
onQtyBlur(item.id); // ← Change this to call onQtyBlur instead!
}}
inputProps={{
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => handleQtyChange(rowKey, e.target.value)}
onBlur={() => onQtyBlur(rowKey)}
disabled={inCreated}
inputProps={{
min: 1,
max: item.currentStockBalance || 0,
step: 1,
style: { textAlign: 'center' }
}}
sx={{
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
textAlign: 'center',
cursor: 'text'
}
}}
disabled={isItemInCreated(item.id)}
}}
/>
</TableCell>
{/* Target Date */}
<TableCell align="right">
<Typography variant="body2">
{item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"}
</Typography>
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
@@ -242,4 +227,4 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
);
};

export default SearchResultsTable;
export default SearchResultsTable;

+ 195
- 76
src/components/PickOrderSearch/WorkbenchPickExecution.tsx Ver arquivo

@@ -30,11 +30,11 @@ import { SessionWithTokens } from "@/config/authConfig";
import {
fetchConsumableWorkbenchPickOrderLotsHierarchical,
reloadConsumableWorkbenchPickOrderLotsHierarchical,
confirmLotSubstitution,
suggestPickOrderWorkbenchV2,
} from "@/app/api/pickOrder/actions";
import { workbenchScanPick } from "@/app/api/doworkbench/actions";
import { fetchStockInLineInfo } from "@/app/api/po/actions";
import { fetchLotDetail } from "@/app/api/inventory/actions";
import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider";
import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";
@@ -66,6 +66,7 @@ type LineRow = {
requiredQty: number;
pickedQty: number;
stockUnit: string;
uomId?: number;
status: string;
lotsRaw: unknown[];
};
@@ -89,6 +90,7 @@ type LotRow = {
itemCode: string;
itemName: string;
uomDesc: string;
uomId?: number;
requiredQty: number;
pickOrderLineRequiredQty?: number;
availableQty: number;
@@ -226,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean =>
const isRejectedStatus = (status: string | undefined): boolean =>
String(status || "").toLowerCase() === "rejected";

const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => {
const c = String(code || "").toUpperCase();
const m = String(message || "");
if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true;
if (/^Reject switch lot:/i.test(m)) return true;
if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true;
return false;
};

function safeDisplayTargetDate(targetDate: string | number[]): string {
try {
if (Array.isArray(targetDate) && targetDate.length >= 3) {
@@ -273,6 +266,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] {
itemCode: line.itemCode,
itemName: line.itemName,
uomDesc: toStr(lot.stockUnit) || line.stockUnit,
uomId: line.uomId,
requiredQty: toNum(lot.requiredQty, line.requiredQty),
pickOrderLineRequiredQty: line.requiredQty,
availableQty: toNum(lot.availableQty),
@@ -333,6 +327,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] {
requiredQty: toNum(line?.requiredQty),
pickedQty: sumLinePickedQtyFromLots(lots),
stockUnit: toStr(item?.uomDesc ?? item?.uomCode),
uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined,
status: toStr(line?.status),
lotsRaw: lots,
};
@@ -351,6 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] {
});
}

/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */
const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -770,6 +766,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
processedQrCodesRef.current.add(latestQr);
}, []);

/** Soft-fail / switch-PO: allow the same QR to be processed again. */
const releaseProcessedQr = useCallback((qr: string) => {
const key = String(qr || "").trim();
if (!key) return;
processedQrCodesRef.current.delete(key);
if (lastProcessedQrRef.current === key) {
lastProcessedQrRef.current = "";
}
}, []);

const openUnpickableScanLotLabelModal = useCallback(
(
pickRow: LotRow,
@@ -946,6 +952,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
}
}, []);

// Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only.
const handleLotConfirmation = useCallback(
async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => {
const expected = overrideExpected ?? expectedLotData;
@@ -956,56 +963,25 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
setError("");
setMessage("");
try {
const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0);
let switchedToUnavailable = false;
if (originalSuggestedPickLotId > 0) {
const res = await confirmLotSubstitution({
pickOrderLineId: expected.row.pickOrderLineId,
stockOutLineId: expected.row.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: scanned.lotNo,
newStockInLineId: Number(scanned.stockInLineId ?? 0),
});
switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE";
const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message);
if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) {
const msg = (res.message as string) || t("Lot switch failed");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
return;
}
if (nonBlockingReject && !switchedToUnavailable) {
const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick.");
setMessage(warnMsg);
}
}

if (!switchedToUnavailable) {
const res = await workbenchScanPick({
stockOutLineId: expected.row.stockOutLineId,
lotNo: scanned.lotNo,
...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0
? { stockInLineId: Number(scanned.stockInLineId) }
: {}),
...workbenchScanPickQtyFromLot(expected.row),
userId,
const res = await workbenchScanPick({
stockOutLineId: expected.row.stockOutLineId,
lotNo: scanned.lotNo,
...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0
? { stockInLineId: Number(scanned.stockInLineId) }
: {}),
...workbenchScanPickQtyFromLot(expected.row),
userId,
});
if (res.code !== "SUCCESS") {
const msg = (res.message as string) || t("Workbench scan-pick failed.");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
if (res.code !== "SUCCESS") {
const msg = (res.message as string) || t("Workbench scan-pick failed.");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
return;
}
return;
}
setMessage(t("Scan pick success"));
startTransition(() => {
@@ -1177,16 +1153,102 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
return;
}

const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem;
const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0];
// Case A: reject only if scanned lot UOM matches none of this item's POL UOMs
// (same item may have multiple lines with different UOMs on one pick order)
let scannedUomId = 0;
const allowedUomIds = new Set(
allLotsForItem
.map((l) => Number(l?.uomId))
.filter((id) => Number.isFinite(id) && id > 0),
);
const hasLineUoms = allowedUomIds.size > 0;
try {
const lotDetail = await fetchLotDetail(scannedStockInLineId);
scannedUomId = Number(lotDetail?.uomId) || 0;
} catch (e) {
if (hasLineUoms) {
console.warn(
"[QR PROCESS] lot-detail UOM check failed; rejecting scan",
e,
);
const msg = t(
"This lot UOM does not match the pick line. Please scan another lot.",
);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
releaseProcessedQr(latest);
return;
}
}
if (
hasLineUoms &&
(!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId))
) {
const msg = t(
"This lot UOM does not match the pick line. Please scan another lot.",
);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
releaseProcessedQr(latest);
return;
}

// Strict UOM filter — never fall back to other UOM lines
const filterByScannedUom = (rows: LotRow[]) => {
if (!(scannedUomId > 0) || !hasLineUoms) return rows;
return rows.filter((r) => Number(r.uomId) === scannedUomId);
};
const activeForUom = filterByScannedUom(activeSuggestedLots);
const allForUom = filterByScannedUom(allLotsForItem);
if (hasLineUoms && scannedUomId > 0 && allForUom.length === 0) {
const msg = t(
"This lot UOM does not match the pick line. Please scan another lot.",
);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
releaseProcessedQr(latest);
return;
}
const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom;
let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0];
if (!expectedRow) {
setError(t("Scanned item is not found in current line"));
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(t("Scanned item is not found in current line"));
});
releaseProcessedQr(latest);
return;
}

const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || [];
const scannedRowInItem =
let scannedRowInItem =
scannedRows.find(
(r) =>
Number(r.itemId) === scannedItemId &&
r.stockOutLineId > 0,
r.stockOutLineId > 0 &&
(!(scannedUomId > 0) || !hasLineUoms || Number(r.uomId) === scannedUomId),
) ||
(!(scannedUomId > 0) || !hasLineUoms
? scannedRows.find(
(r) =>
Number(r.itemId) === scannedItemId &&
r.stockOutLineId > 0,
)
: undefined) ||
null;

if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) {
@@ -1231,14 +1293,46 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
return;
}

if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) {
setError(t("Scanned lot is already completed or checked"));
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(t("Scanned lot is already completed or checked"));
// Same lot already bound to a completed/checked SOL: only reuse for another
// pending line with the SAME uomId (never cross UOM A ↔ B).
if (
scannedRowInItem &&
(isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))
) {
const completedPolId = Number(scannedRowInItem.pickOrderLineId);
const completedSolId = Number(scannedRowInItem.stockOutLineId);
const pendingPoolBase = activeForUom.length > 0 ? activeForUom : allForUom;
const pendingSameUom = pendingPoolBase.filter((r) => {
if (!(r.stockOutLineId > 0)) return false;
if (isCompletedStatus(r.status) || isCheckedStatus(r.status) || isRejectedStatus(r.status)) {
return false;
}
if (Number(r.stockOutLineId) === completedSolId) return false;
if (completedPolId > 0 && Number(r.pickOrderLineId) === completedPolId) return false;
// Require explicit same UOM — do not reuse across different UOM lines
if (scannedUomId > 0 && hasLineUoms) {
return Number(r.uomId) === scannedUomId;
}
// Without line UOM metadata, do not auto-reuse (avoids A/B cross-pick)
return false;
});
return;
const nextExpected =
pickExpectedRowForSubstitution(pendingSameUom) || pendingSameUom[0] || null;
if (nextExpected) {
expectedRow = nextExpected;
// Force substitution path using scanned SIL, not the completed SOL row.
scannedRowInItem = null;
} else {
const msg = t("This lot has already been picked. Please scan another lot.");
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
releaseProcessedQr(latest);
return;
}
}

let scannedState: ConfirmLotState | null = null;
@@ -1292,6 +1386,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
pickExpectedRowForSubstitution,
lotRowIndexes,
openUnpickableScanLotLabelModal,
releaseProcessedQr,
resetScan,
submitRow,
t,
@@ -1306,10 +1401,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
}, [isScanning, startScan, userId]);

useEffect(() => {
if (!selectedPickOrderId) {
lastProcessedQrRef.current = "";
processedQrCodesRef.current.clear();
}
// Clear on any PO selection change (including PO1 → PO2), so the same lot QR can be scanned again.
lastProcessedQrRef.current = "";
processedQrCodesRef.current.clear();
}, [selectedPickOrderId]);

useEffect(() => {
@@ -1521,6 +1615,11 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
const isRowExpired =
isWorkbenchSourceLotExpired(r) && !isRowRejected;
const isRowUnavailable = isInventoryLotLineUnavailable(r);
const isRowComplete =
rowStatus === "completed" ||
rowStatus === "checked" ||
rowStatus === "partially_completed" ||
rowStatus === "partially_complete";

return (
<TableRow key={r.key}>
@@ -1544,7 +1643,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
? "error.main"
: isRowExpired || isLotAvailabilityExpired(r)
? "warning.main"
: "inherit",
: isRowComplete
? "success.main"
: "inherit",
}}
>
{r.lotNo ? (
@@ -1699,7 +1800,19 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
return { ...prev, [r.stockOutLineId]: n };
});
}}
sx={{ width: 96 }}
sx={{
width: 96,
"& .MuiInputBase-input": {
typography: "body1",
py: 0.5,
textAlign: "center",
"&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
MozAppearance: "textfield",
},
}}
disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true}
inputProps={{ min: 0, step: 1 }}
/>
@@ -1779,6 +1892,12 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
statusTitleSeverity={workbenchLotLabelStatusBanner.severity}
triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null}
triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null}
expectedUomId={
workbenchLotLabelContextLot != null &&
Number(workbenchLotLabelContextLot.uomId) > 0
? Number(workbenchLotLabelContextLot.uomId)
: null
}
submitQty={
workbenchLotLabelContextLot?.stockOutLineId
? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot))


+ 139
- 137
src/components/PickOrderSearch/newcreatitem.tsx Ver arquivo

@@ -70,6 +70,8 @@ interface CreatedItem {

// Add interface for search items with quantity
interface SearchItemWithQty extends ItemCombo {
/** Unique per item+uom search row */
rowKey: string;
qty: number | null; // Changed from number to number | null
jobOrderCode?: string;
jobOrderId?: number;
@@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo {
targetDate?: string | null; // Allow null values
groupId?: number | null; // Allow null values
}

const toSearchRowKey = (itemId: number, uomId?: number | null) =>
`${itemId}_${Number(uomId) > 0 ? Number(uomId) : 0}`;

interface JobOrderDetailPickLine {
id: number;
code: string;
@@ -95,8 +101,9 @@ interface Group {
}
// Move the counter outside the component to persist across re-renders
let checkboxChangeCallCount = 0;
let processingItems = new Set<number>();
let processingItems = new Set<string>();

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => {
const { t } = useTranslation("pickOrder");
const [items, setItems] = useState<ItemCombo[]>([]);
@@ -174,6 +181,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
uom: item.uom,
uomId: 0,
uomDesc: item.uomDesc || "", // Add missing uomDesc
rowKey: toSearchRowKey(item.id, 0),
jobOrderCode: jobOrderDetail.code,
jobOrderId: jobOrderDetail.id,
currentStockBalance: 0, // Add default value
@@ -263,49 +271,50 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
);
}, []);

// Modified handler for search item selection
// Modified handler for search item selection (legacy filteredItems path)
const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => {
if (isSelected) {
const item = filteredItems.find(i => i.id === itemId);
if (!item) return;
const existingItem = createdItems.find(created => created.itemId === item.id);
if (existingItem) {
const uomId = Number(item.uomId) || 0;
if (createdItems.some((c) => c.itemId === item.id && Number(c.uomId) === uomId)) {
alert(t("Item already exists in created items"));
return;
}
// Fix the newCreatedItem creation - add missing uomDesc
const newCreatedItem: CreatedItem = {
itemId: item.id,
itemName: item.label,
itemCode: item.label,
qty: item.qty || 1,
uom: item.uom || "",
uomId: item.uomId || 0,
uomDesc: item.uomDesc || "", // Add missing uomDesc
uomId,
uomDesc: item.uomDesc || "",
isSelected: true,
currentStockBalance: item.currentStockBalance,
targetDate: item.targetDate || targetDate, // Use item's targetDate or fallback to form's targetDate
groupId: item.groupId || undefined, // Handle null values
targetDate: item.targetDate || targetDate,
groupId: item.groupId || undefined,
};
setCreatedItems(prev => [...prev, newCreatedItem]);
}
}, [filteredItems, createdItems, t, targetDate]);

// Handler for created item selection
const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => {
const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => {
setCreatedItems(prev =>
prev.map(item =>
item.itemId === itemId ? { ...item, isSelected } : item
item.itemId === itemId &&
(uomId == null || Number(item.uomId) === Number(uomId))
? { ...item, isSelected }
: item
)
);
}, []);

const handleQtyChange = useCallback((itemId: number, newQty: number) => {
const handleQtyChange = useCallback((itemId: number, newQty: number, uomId?: number) => {
setCreatedItems(prev =>
prev.map(item =>
item.itemId === itemId
item.itemId === itemId &&
(uomId == null || Number(item.uomId) === Number(uomId))
? {
...item,
qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))),
@@ -315,18 +324,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
);
}, []);

// Check if item is already in created items
const isItemInCreated = useCallback((itemId: number) => {
return createdItems.some(item => item.itemId === itemId);
// Check if item (+ optional uom) is already in created items
const isItemInCreated = useCallback((itemId: number, uomId?: number) => {
return createdItems.some(
(item) =>
item.itemId === itemId &&
(uomId == null || Number(item.uomId) === Number(uomId)),
);
}, [createdItems]);

// 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期
const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string) => {
const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string, uomId?: number) => {
const gid = newGroupId ? Number(newGroupId) : undefined;
const group = groups.find(g => g.id === gid);
setCreatedItems(prev =>
prev.map(it =>
it.itemId === itemId
it.itemId === itemId &&
(uomId == null || Number(it.uomId) === Number(uomId))
? {
...it,
groupId: gid,
@@ -410,27 +424,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
}
}, [t]);
const checkAndAutoAddItem = useCallback((itemId: number) => {
const item = secondSearchResults.find(i => i.id === itemId);
const checkAndAutoAddItem = useCallback((rowKey: string) => {
const item = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey);
if (!item) return;

// Check if item has ALL 3 conditions:
// 1. Item is selected (checkbox checked)
const isSelected = selectedSecondSearchItemIds.includes(itemId);
// 2. Group is assigned
const key = item.rowKey || toSearchRowKey(item.id, item.uomId);
const isSelected = selectedSecondSearchItemIds.includes(key);
const hasGroup = item.groupId !== undefined && item.groupId !== null;
// 3. Quantity is entered
const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0;
if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) {
// Auto-add to created items
if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id, item.uomId)) {
const newCreatedItem: CreatedItem = {
itemId: item.id,
itemName: item.label,
itemCode: item.label,
qty: item.qty || 1,
uom: item.uom || "",
uomId: item.uomId || 0,
uomId: Number(item.uomId) || 0,
uomDesc: item.uomDesc || "",
isSelected: true,
currentStockBalance: item.currentStockBalance,
@@ -438,33 +448,29 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
groupId: item.groupId || undefined,
};
setCreatedItems(prev => [...prev, newCreatedItem]);
// Remove from search results since it's now in created items
setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId));
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId));
setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key));
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key));
}
}, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]);
// Add this function after checkAndAutoAddItem
// Add this function after checkAndAutoAddItem
const handleQtyBlur = useCallback((itemId: number) => {
// Only auto-add if item is already selected (scenario 1: select first, then enter quantity)

const handleQtyBlur = useCallback((rowKey: string) => {
setTimeout(() => {
const currentItem = secondSearchResults.find(i => i.id === itemId);
const currentItem = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey);
if (!currentItem) return;

const isSelected = selectedSecondSearchItemIds.includes(itemId);
const key = currentItem.rowKey || toSearchRowKey(currentItem.id, currentItem.uomId);
const isSelected = selectedSecondSearchItemIds.includes(key);
const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null;
const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0;
// Only auto-add if item is already selected (scenario 1: select first, then enter quantity)
if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) {
if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id, currentItem.uomId)) {
const newCreatedItem: CreatedItem = {
itemId: currentItem.id,
itemName: currentItem.label,
itemCode: currentItem.label,
qty: currentItem.qty || 1,
uom: currentItem.uom || "",
uomId: currentItem.uomId || 0,
uomId: Number(currentItem.uomId) || 0,
uomDesc: currentItem.uomDesc || "",
isSelected: true,
currentStockBalance: currentItem.currentStockBalance,
@@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => {
groupId: currentItem.groupId || undefined,
};
setCreatedItems(prev => [...prev, newCreatedItem]);
setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId));
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId));
setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key));
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key));
}
}, 0);
}, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]);
const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => {

const handleSearchItemGroupChange = useCallback((rowKey: string, groupId: string) => {
const gid = groupId ? Number(groupId) : undefined;
const group = groups.find(g => g.id === gid);
setSecondSearchResults(prev => prev.map(item =>
item.id === itemId
(item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey
? {
...item,
groupId: gid,
@@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => {
: item
));
// Check auto-add after group assignment
setTimeout(() => {
checkAndAutoAddItem(itemId);
checkAndAutoAddItem(rowKey);
}, 0);
}, [groups, checkAndAutoAddItem]);
// 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group)
@@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
uomId: item.uomId,
uom: item.uom,
uomDesc: item.uomDesc,
rowKey: toSearchRowKey(item.id, item.uomId),
currentStockBalance: item.currentStockBalance,
qty: null,
targetDate: targetDate,
@@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
name: "id",
label: "",
type: "checkbox",
disabled: (item) => isItemInCreated(item.id), // Disable if already in created items
disabled: (item) => isItemInCreated(item.id, item.uomId), // Disable if already in created items
},

{
@@ -1141,7 +1147,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
}, [formProps]);

// 添加数量变更处理函数
const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => {
const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => {
const getClampedQty = (qty: number | null, stock?: number) => {
if (qty === null) return null;
const maxQty = Math.max(0, stock ?? 0);
@@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => {

setSecondSearchResults(prev =>
prev.map(item =>
item.id === itemId ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } : item
(item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey
? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) }
: item
)
);
@@ -1163,24 +1171,24 @@ const handleQtyBlur = useCallback((itemId: number) => {
const newIds = ids(selectedSecondSearchItemIds);
setSelectedSecondSearchItemIds(newIds);
// 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面
if (newIds.length === secondSearchResults.length) {
// 全选:将所有搜索结果添加到创建项目
secondSearchResults.forEach(item => {
if (!isItemInCreated(item.id)) {
if (!isItemInCreated(item.id, item.uomId)) {
handleSearchItemSelect(item.id, true);
}
});
} else {
// 部分选择:只处理当前页面的选择
secondSearchResults.forEach(item => {
const isSelected = newIds.includes(item.id);
const isCurrentlyInCreated = isItemInCreated(item.id);
const key = item.rowKey || toSearchRowKey(item.id, item.uomId);
const isSelected = newIds.includes(key);
const isCurrentlyInCreated = isItemInCreated(item.id, item.uomId);
if (isSelected && !isCurrentlyInCreated) {
handleSearchItemSelect(item.id, true);
} else if (!isSelected && isCurrentlyInCreated) {
setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id));
setCreatedItems(prev => prev.filter(createdItem =>
!(createdItem.itemId === item.id && Number(createdItem.uomId) === Number(item.uomId))
));
}
});
}
@@ -1192,13 +1200,19 @@ const handleQtyBlur = useCallback((itemId: number) => {
const newlyDeselected = previousIds.filter(id => !ids.includes(id));
newlySelected.forEach(id => {
if (!isItemInCreated(id as number)) {
handleSearchItemSelect(id as number, true);
const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id));
if (row && !isItemInCreated(row.id, row.uomId)) {
handleSearchItemSelect(row.id, true);
}
});
newlyDeselected.forEach(id => {
setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id));
const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id));
if (row) {
setCreatedItems(prev => prev.filter(createdItem =>
!(createdItem.itemId === row.id && Number(createdItem.uomId) === Number(row.uomId))
));
}
});
}
}, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]);
@@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
name: "id",
label: "",
type: "checkbox",
disabled: (item) => isItemInCreated(item.id),
disabled: (item) => isItemInCreated(item.id, item.uomId),
},
{
name: "label",
@@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
renderCell: (item) => (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */}
{item.uom || "-"}
{item.uomDesc || item.uom || "-"}
</Typography>
</Box>
),
@@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
handleSecondSearchQtyChange(item.id, numValue);
handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue);
}
}}
inputProps={{
@@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
const value = e.target.value;
const numValue = value === "" ? null : Number(value);
if (numValue !== null && numValue < 1) {
handleSecondSearchQtyChange(item.id, 1); // Enforce min value
handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), 1); // Enforce min value
}
}}
/>
@@ -1368,6 +1382,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
uomId: item.uomId,
uom: item.uom,
uomDesc: item.uomDesc,
rowKey: toSearchRowKey(item.id, item.uomId),
currentStockBalance: item.currentStockBalance,
qty: null,
targetDate: undefined,
@@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => {
}, []);
const getValidationMessage = useCallback(() => {
const selectedItems = secondSearchResults.filter(item =>
selectedSecondSearchItemIds.includes(item.id)
selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))
);
const itemsWithoutGroup = selectedItems.filter(item =>
@@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => {
// Fix the handleAddSelectedToCreatedItems function to properly clear selections
const handleAddSelectedToCreatedItems = useCallback(() => {
const selectedItems = secondSearchResults.filter(item =>
selectedSecondSearchItemIds.includes(item.id)
selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))
);
// Add selected items to created items with their own group info
selectedItems.forEach(item => {
if (!isItemInCreated(item.id)) {
const newCreatedItem: CreatedItem = {
itemId: item.id,
itemName: item.label,
itemCode: item.label,
qty: item.qty || 1,
uom: item.uom || "",
uomId: item.uomId || 0,
uomDesc: item.uomDesc || "",
isSelected: true,
currentStockBalance: item.currentStockBalance,
targetDate: item.targetDate || targetDate,
groupId: item.groupId || undefined,
};
setCreatedItems(prev => [...prev, newCreatedItem]);
}
});
// Clear the selection

const toAdd: CreatedItem[] = [];
const addedKeys: string[] = [];
for (const item of selectedItems) {
const key = item.rowKey || toSearchRowKey(item.id, item.uomId);
if (isItemInCreated(item.id, item.uomId)) continue;
toAdd.push({
itemId: item.id,
itemName: item.label,
itemCode: item.label,
qty: item.qty || 1,
uom: item.uom || "",
uomId: Number(item.uomId) || 0,
uomDesc: item.uomDesc || "",
isSelected: true,
currentStockBalance: item.currentStockBalance,
targetDate: item.targetDate || targetDate,
groupId: item.groupId || undefined,
});
addedKeys.push(key);
}
if (toAdd.length > 0) {
setCreatedItems((prev) => [...prev, ...toAdd]);
}
setSelectedSecondSearchItemIds([]);
// Remove the selected/added items from search results entirely
setSecondSearchResults(prev => prev.filter(item =>
!selectedSecondSearchItemIds.includes(item.id)
));
setSecondSearchResults((prev) =>
prev.filter((item) => !addedKeys.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))),
);
}, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]);

// Add a validation function to check if selected items are valid
const areSelectedItemsValid = useCallback(() => {
const selectedItems = secondSearchResults.filter(item =>
selectedSecondSearchItemIds.includes(item.id)
selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))
);
return selectedItems.every(item =>
@@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => {

// Move these handlers to the component level (outside of CustomSearchResultsTable)
// Handle individual checkbox change - ONLY select, don't add to created items
const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => {
const handleIndividualCheckboxChange = useCallback((rowKey: string, checked: boolean) => {
checkboxChangeCallCount++;
if (checked) {
// Add to selected IDs
setSelectedSecondSearchItemIds(prev => [...prev, itemId]);
setSelectedSecondSearchItemIds(prev => [...prev, rowKey]);
// Set the item's group and targetDate to current group when selected
setSecondSearchResults(prev => {
const updatedResults = prev.map(item =>
item.id === itemId
(item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey
? {
...item,
groupId: selectedGroup?.id || undefined,
@@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => {
// Check if should auto-add after state update
setTimeout(() => {
// Check if we're already processing this item
if (processingItems.has(itemId)) {
//alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`);
if (processingItems.has(rowKey)) {
return;
}
const updatedItem = updatedResults.find(i => i.id === itemId);
const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey);
if (updatedItem) {
const isSelected = true; // We just selected it
const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null;
const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0;
// Only auto-add if item has quantity (scenario 2: enter quantity first, then select)
if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) {
// Mark this item as being processed
processingItems.add(itemId);
if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id, updatedItem.uomId)) {
processingItems.add(rowKey);

const newCreatedItem: CreatedItem = {
itemId: updatedItem.id,
itemName: updatedItem.label,
itemCode: updatedItem.label,
qty: updatedItem.qty || 1,
uom: updatedItem.uom || "",
uomId: updatedItem.uomId || 0,
uomId: Number(updatedItem.uomId) || 0,
uomDesc: updatedItem.uomDesc || "",
isSelected: true,
currentStockBalance: updatedItem.currentStockBalance,
@@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => {
groupId: updatedItem.groupId || undefined,
};
setCreatedItems(prev => [...prev, newCreatedItem]);
setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId));
setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId));
// Remove from processing set after a short delay
setSecondSearchResults(current => current.filter(searchItem =>
(searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== rowKey
));
setSelectedSecondSearchItemIds(current => current.filter(id => id !== rowKey));
setTimeout(() => {
processingItems.delete(itemId);
processingItems.delete(rowKey);
}, 100);
}
// Show final debug info in one alert
/*
alert(`FINAL DEBUG INFO for item ${itemId}:
Function called ${checkboxChangeCallCount} times
Is Selected: ${isSelected}
Has Group: ${hasGroup}
Has Quantity: ${hasQty}
Quantity: ${updatedItem.qty}
Group ID: ${updatedItem.groupId}
Is Item In Created: ${isItemInCreated(updatedItem.id)}
Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)}
Processing items: ${Array.from(processingItems).join(', ')}`);
*/
}
}, 0);
@@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`);
});
} else {
// Remove from selected IDs
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId));
setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== rowKey));
// Clear the item's group and targetDate when deselected
setSecondSearchResults(prev => prev.map(item =>
item.id === itemId
(item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey
? {
...item,
groupId: undefined,
@@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S
if (checked) {
// Select all items on current page that are not already in created items
const newSelectedIds = paginatedResults
.filter(item => !isItemInCreated(item.id))
.map(item => item.id);
.filter(item => !isItemInCreated(item.id, item.uomId))
.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId));
setSelectedSecondSearchItemIds(prev => {
const existingIds = prev.filter(id => !paginatedResults.some(item => item.id === id));
const existingIds = prev.filter(id => !paginatedResults.some(item =>
(item.rowKey || toSearchRowKey(item.id, item.uomId)) === id
));
return [...existingIds, ...newSelectedIds];
});
// Set group and targetDate for all selected items on current page
setSecondSearchResults(prev => prev.map(item =>
newSelectedIds.includes(item.id)
newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))
? {
...item,
groupId: selectedGroup?.id || undefined,
@@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S
));
} else {
// Deselect all items on current page
const pageItemIds = paginatedResults.map(item => item.id);
setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(id as number)));
const pageItemIds = paginatedResults.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId));
setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(String(id))));
// Clear group and targetDate for all deselected items on current page
setSecondSearchResults(prev => prev.map(item =>
pageItemIds.includes(item.id)
pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))
? {
...item,
groupId: undefined,


+ 350
- 0
src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx Ver arquivo

@@ -0,0 +1,350 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Box,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Stack,
IconButton,
Collapse,
Link as MuiLink,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import NextLink from "next/link";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import type { Dayjs } from "dayjs";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import {
fetchDrinkProductionQty,
DrinkProductionQtyResponse,
} from "@/app/api/jo/actions";

const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘

const formatQty = (qty: number | null | undefined): string => {
if (qty === null || qty === undefined || Number.isNaN(qty)) return "-";
return qty.toLocaleString(undefined, { maximumFractionDigits: 2 });
};

const formatProductionDate = (value: string | null | undefined): string => {
if (!value) return "-";
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value;
};

const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string =>
`${row.itemCode || "unknown"}-${idx}`;

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
const DrinkProductionQtyDashboard: React.FC = () => {
const { t } = useTranslation(["common", "jo", "productionProcess"]);
const [data, setData] = useState<DrinkProductionQtyResponse[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>(
new Set(),
);
const refreshCountRef = useRef<number>(0);
const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(
null,
);

const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchDrinkProductionQty(
selectedDate.format("YYYY-MM-DD"),
);
setData(result || []);
setExpandedRowKeys(new Set());
setLastDataRefreshTime(dayjs());
refreshCountRef.current += 1;
} catch (error) {
console.error("Error fetching drink production qty:", error);
setData([]);
setExpandedRowKeys(new Set());
} finally {
setLoading(false);
}
}, [selectedDate]);

useEffect(() => {
loadData();
const interval = setInterval(() => {
loadData();
}, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [loadData]);

const toggleRowExpanded = (rowKey: string) => {
setExpandedRowKeys((prev) => {
const next = new Set(prev);
if (next.has(rowKey)) {
next.delete(rowKey);
} else {
next.add(rowKey);
}
return next;
});
};

return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{t("Drink Production Qty Dashboard")}
</Typography>

<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: "center" }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={t("Date")}
value={selectedDate}
onChange={(newValue) => {
if (newValue) setSelectedDate(newValue);
}}
format="YYYY-MM-DD"
slotProps={{
textField: { size: "small", sx: { minWidth: 160 } },
}}
/>
</LocalizationProvider>

<Box sx={{ flexGrow: 1 }} />

<Typography
variant="body2"
sx={{ color: "text.secondary" }}
suppressHydrationWarning
>
{t("Auto-refresh every 10 minutes")}&nbsp;|&nbsp;
{t("Last updated")}:{" "}
{lastDataRefreshTime
? lastDataRefreshTime.format("HH:mm:ss")
: "--:--:--"}
</Typography>
</Stack>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer
sx={{
border: "3px solid #135fed",
overflowX: "auto",
maxHeight: 440,
overflow: "auto",
}}
component={Paper}
>
<Table size="small" sx={{ minWidth: 650 }}>
<TableHead>
<TableRow
sx={{
bgcolor: "#424242",
"& th": {
borderBottom: "none",
py: 1.5,
position: "sticky",
top: 0,
zIndex: 1,
},
}}
>
<TableCell sx={{ width: 48 }} />
<TableCell sx={{ width: 160 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Item Code")}
</Typography>
</TableCell>
<TableCell sx={{ width: 260 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Goods Name")}
</Typography>
</TableCell>
<TableCell sx={{ width: 120 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Unit")}
</Typography>
</TableCell>
<TableCell align="right" sx={{ width: 120 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Stock Req. Qty")}
</Typography>
</TableCell>
<TableCell align="right" sx={{ width: 140 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Production Qty")}
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography
variant="body2"
sx={{ py: 2, color: "text.secondary" }}
>
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
data.map((row, idx) => {
const rowKey = getRowKey(row, idx);
const jobOrders = row.jobOrders ?? [];
const isExpanded = expandedRowKeys.has(rowKey);
const hasJobOrders = jobOrders.length > 0;

return (
<React.Fragment key={rowKey}>
<TableRow hover={hasJobOrders}>
<TableCell padding="checkbox">
{hasJobOrders ? (
<IconButton
size="small"
aria-label={
isExpanded
? t("Collapse job order details")
: t("Expand job order details")
}
onClick={() => toggleRowExpanded(rowKey)}
>
{isExpanded ? (
<ExpandLessIcon fontSize="small" />
) : (
<ExpandMoreIcon fontSize="small" />
)}
</IconButton>
) : null}
</TableCell>
<TableCell>
<Typography variant="body2">
{row.itemCode || "-"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{row.itemName || "-"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{row.uom || "-"}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{formatQty(row.totalReqQty)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{formatQty(row.totalQty)}
</Typography>
</TableCell>
</TableRow>
{hasJobOrders && (
<TableRow>
<TableCell
colSpan={6}
sx={{ py: 0, borderBottom: 0 }}
>
<Collapse
in={isExpanded}
timeout="auto"
unmountOnExit
>
<Box sx={{ py: 1.5, pl: 6, pr: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>
{t("Job Order Code")}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{t("Production Date")}
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600 }}
>
{t("Stock Req. Qty")}
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600 }}
>
{t("Production Qty")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobOrders.map((jo) => (
<TableRow
key={`${rowKey}-jo-${jo.jobOrderId}`}
>
<TableCell>
{jo.jobOrderId > 0 ? (
<MuiLink
component={NextLink}
href={`/jo/edit?id=${jo.jobOrderId}`}
underline="hover"
>
{jo.jobOrderCode ||
`JO-${jo.jobOrderId}`}
</MuiLink>
) : (
jo.jobOrderCode || "-"
)}
</TableCell>
<TableCell>
{formatProductionDate(
jo.productionDate,
)}
</TableCell>
<TableCell align="right">
{formatQty(jo.reqQty)}
</TableCell>
<TableCell align="right">
{formatQty(jo.productionQty)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
);
};

export default DrinkProductionQtyDashboard;

+ 750
- 0
src/components/ProductionProcess/JobOrderOpsTable.tsx Ver arquivo

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

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs, { Dayjs } from "dayjs";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH } from "@/authorities";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import {
AllJoborderProductProcessInfoResponse,
completeProductProcessLine,
fetchJoborderProductProcessesPage,
fetchJos,
fetchProductProcessesByJobOrderId,
setJobOrderHidden,
} from "@/app/api/jo/actions";
import { JobOrder } from "@/app/api/jo";
import QcStockInModal from "@/components/Qc/QcStockInModal";
import { StockInLineInput } from "@/app/api/stockIn";
import type { PrinterCombo } from "@/app/api/settings/printer";

type PrimaryTab =
| "all"
| "pending"
| "processing"
| "pending_qc"
| "putawayed"
| "issue";
type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started";
type ProcessingSubTab = "all" | "picked_started" | "not_picked_started";
type IssueSubTab = "stop" | "cancel";

type OpsRow = {
key: string;
jobOrderId: number;
jobOrderCode: string;
itemCode: string;
itemName: string;
requiredQty: number;
uom: string;
productionDate: string;
statusLabel: string;
pickProcessBucket?: string | null;
isPaused: boolean;
isCancelled: boolean;
/** pending_qc | putawayed | production | cancelled */
rowKind: "production" | "pending_qc" | "putawayed" | "cancelled";
stockInLineId?: number | null;
/** Present for production rows; used by complete-JO. */
sourceProcess?: AllJoborderProductProcessInfoResponse;
};

/** Cards / page size for ops table fetch. */
const FETCH_SIZE = 200;

function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean {
return (p.lines ?? []).some(
(l) => String(l.status ?? "").trim().toLowerCase() === "paused",
);
}

function toProductionRow(
p: AllJoborderProductProcessInfoResponse,
rowKind: OpsRow["rowKind"] = "production",
): OpsRow | null {
if (!p.jobOrderId) return null;
return {
key: `pp-${rowKind}-${p.jobOrderId}-${p.id}`,
jobOrderId: p.jobOrderId,
jobOrderCode: p.jobOrderCode || "-",
itemCode: p.itemCode || "-",
itemName: p.itemName || "-",
requiredQty: p.requiredQty ?? 0,
uom: p.uom || "",
productionDate: p.date || "",
statusLabel:
rowKind === "pending_qc"
? "pending_qc"
: rowKind === "putawayed"
? "putawayed"
: p.pickProcessBucket || p.status || "-",
pickProcessBucket: p.pickProcessBucket,
isPaused: isPausedProcess(p),
isCancelled: false,
rowKind,
stockInLineId: p.stockInLineId ?? null,
sourceProcess: p,
};
}

function toCancelledRow(jo: JobOrder): OpsRow {
const planStart = Array.isArray(jo.planStart)
? dayjs(
new Date(
jo.planStart[0],
(jo.planStart[1] ?? 1) - 1,
jo.planStart[2] ?? 1,
),
).format("YYYY-MM-DD")
: jo.planStart
? dayjs(jo.planStart as unknown as string).format("YYYY-MM-DD")
: "";
return {
key: `jo-${jo.id}`,
jobOrderId: jo.id,
jobOrderCode: jo.code || "-",
itemCode: jo.item?.code || "-",
itemName: jo.itemName || jo.item?.name || "-",
requiredQty: jo.reqQty ?? 0,
uom: "",
productionDate: planStart,
statusLabel: "cancelled",
pickProcessBucket: null,
isPaused: false,
isCancelled: true,
rowKind: "cancelled",
stockInLineId: null,
};
}

/** Deduplicate by jobOrderId (prefer paused / higher priority). */
function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] {
const map = new Map<number, OpsRow>();
for (const row of rows) {
const existing = map.get(row.jobOrderId);
if (!existing) {
map.set(row.jobOrderId, row);
continue;
}
if (!existing.isPaused && row.isPaused) {
map.set(row.jobOrderId, row);
}
}
return Array.from(map.values());
}

interface JobOrderOpsTableProps {
onSelectProcess?: (jobOrderId: number) => void;
printerCombo?: PrinterCombo[];
}

const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
onSelectProcess,
printerCombo = [],
}) => {
const { t } = useTranslation(["productionProcess", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN);

const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("all");
const [pendingSub, setPendingSub] = useState<PendingSubTab>("all");
const [processingSub, setProcessingSub] = useState<ProcessingSubTab>("all");
const [issueSub, setIssueSub] = useState<IssueSubTab>("stop");

const [productionRows, setProductionRows] = useState<OpsRow[]>([]);
const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]);
const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]);
const [cancelledRows, setCancelledRows] = useState<OpsRow[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(10);

const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmMessage, setConfirmMessage] = useState("");
const [confirmLoading, setConfirmLoading] = useState(false);
const [pendingAction, setPendingAction] = useState<null | (() => Promise<void>)>(null);
const actionInFlightRef = useRef(false);
const [rowBusyIds, setRowBusyIds] = useState<Set<number>>(new Set());
const [qcModalOpen, setQcModalOpen] = useState(false);
const [qcModalInfo, setQcModalInfo] = useState<StockInLineInput | undefined>();

const loadProduction = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
// lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend)
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
bucket: "all",
qcReady: false,
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "production"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setProductionRows(dedupeByJobOrder(rows));
}, [queryDate]);

const loadPendingQc = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "pending_qc"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setPendingQcRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPendingQc failed", e);
setPendingQcRows([]);
}
}, [queryDate]);

const loadPutawayed = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
qcReady: true,
includePutaway: true,
putawayStatus: "completed",
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "putawayed"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setPutawayedRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPutawayed failed", e);
setPutawayedRows([]);
}
}, [queryDate]);

const loadCancelled = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const res = await fetchJos({
code: "",
planStart: dayStr,
planStartTo: dayStr,
joSearchStatus: "cancel",
pageNum: 0,
pageSize: FETCH_SIZE,
});
setCancelledRows((res?.records ?? []).map(toCancelledRow));
} catch (e) {
console.error("loadCancelled failed", e);
setCancelledRows([]);
}
}, [queryDate]);

const loadData = useCallback(async () => {
setLoading(true);
const results = await Promise.allSettled([
loadProduction(),
loadPendingQc(),
loadPutawayed(),
loadCancelled(),
]);
if (results[0].status === "rejected") {
console.error(results[0].reason);
setProductionRows([]);
}
setLoading(false);
}, [loadProduction, loadPendingQc, loadPutawayed, loadCancelled]);

useEffect(() => {
void loadData();
}, [loadData]);

useEffect(() => {
setPage(0);
}, [primaryTab, pendingSub, processingSub, issueSub, queryDate]);

const filteredRows = useMemo(() => {
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "putawayed") return putawayedRows;
if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows;

let list = productionRows;
if (primaryTab === "pending") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (pendingSub === "picked_not_started") return b === "picked_not_started";
if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started";
return b === "picked_not_started" || b === "not_picked_not_started";
});
} else if (primaryTab === "processing") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (processingSub === "picked_started") return b === "picked_started";
if (processingSub === "not_picked_started") return b === "not_picked_started";
return b === "picked_started" || b === "not_picked_started";
});
} else if (primaryTab === "issue" && issueSub === "stop") {
list = list.filter((r) => r.isPaused);
}
return list;
}, [
primaryTab,
pendingSub,
processingSub,
issueSub,
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
]);

const counts = useMemo(() => {
const pending = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_not_started" ||
r.pickProcessBucket === "not_picked_not_started",
).length;
const processing = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_started" ||
r.pickProcessBucket === "not_picked_started",
).length;
const stop = productionRows.filter((r) => r.isPaused).length;
return {
all: productionRows.length,
pending,
processing,
pendingQc: pendingQcRows.length,
putawayed: putawayedRows.length,
stop,
cancel: cancelledRows.length,
};
}, [productionRows, pendingQcRows, putawayedRows, cancelledRows]);

const paginatedRows = useMemo(() => {
const start = page * pageSize;
return filteredRows.slice(start, start + pageSize);
}, [filteredRows, page, pageSize]);

const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
setConfirmMessage(message);
setPendingAction(() => action);
setConfirmOpen(true);
}, []);

const closeConfirm = useCallback(() => {
if (confirmLoading) return;
setConfirmOpen(false);
setPendingAction(null);
setConfirmMessage("");
}, [confirmLoading]);

const markBusy = (id: number, busy: boolean) => {
setRowBusyIds((prev) => {
const next = new Set(prev);
if (busy) next.add(id);
else next.delete(id);
return next;
});
};

const handleOpenQcModal = useCallback(
(row: OpsRow) => {
if (!row.stockInLineId) {
alert(t("Invalid Stock In Line Id"));
return;
}
setQcModalInfo({ id: row.stockInLineId });
setQcModalOpen(true);
},
[t],
);

const handleComplete = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
openConfirm(t("Confirm to update this Job Order?"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
markBusy(row.jobOrderId, true);
try {
const processes = await fetchProductProcessesByJobOrderId(row.jobOrderId);
const lineIds = (processes ?? [])
.flatMap((p) => (p as { productProcessLines?: { id?: number }[] }).productProcessLines ?? [])
.map((l) => l.id)
.filter((id): id is number => !!id);
for (const lineId of lineIds) {
try {
await completeProductProcessLine(lineId);
} catch (e) {
console.error("completeProductProcessLine failed", lineId, e);
}
}
await loadData();
} finally {
markBusy(row.jobOrderId, false);
actionInFlightRef.current = false;
}
});
},
[canManage, openConfirm, t, loadData],
);

const handleCancel = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
openConfirm(t("Cancel job order confirm message"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
markBusy(row.jobOrderId, true);
try {
await setJobOrderHidden(row.jobOrderId, true);
await loadData();
} finally {
markBusy(row.jobOrderId, false);
actionInFlightRef.current = false;
}
});
},
[canManage, openConfirm, t, loadData],
);

const onConfirm = useCallback(async () => {
if (!pendingAction) return;
setConfirmLoading(true);
try {
await pendingAction();
} catch (e) {
console.error(e);
} finally {
setConfirmLoading(false);
setConfirmOpen(false);
setPendingAction(null);
setConfirmMessage("");
}
}, [pendingAction]);

const bucketLabel = (row: OpsRow) => {
if (row.rowKind === "pending_qc") return t("Waiting QC Put Away");
if (row.rowKind === "putawayed") return t("Put Awayed");
if (row.isCancelled) return t("Cancelled");
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "not_picked_started":
return t("Not picked");
case "picked_not_started":
case "picked_started":
return t("Picked");
default:
return row.statusLabel || "-";
}
};

const showManageActions =
primaryTab !== "pending_qc" &&
primaryTab !== "putawayed" &&
!(primaryTab === "issue" && issueSub === "cancel");

return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{t("Job Order Ops Table")}
</Typography>

<Stack
direction="row"
spacing={2}
sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}
>
<DatePicker
label={t("Search date")}
value={queryDate}
onChange={(v) => v && setQueryDate(v)}
slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }}
/>
<Button variant="outlined" size="small" onClick={() => void loadData()}>
{t("Reload data")}
</Button>
</Stack>

<Tabs
value={primaryTab}
onChange={(_, v: PrimaryTab) => setPrimaryTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }}
>
<Tab value="all" label={`${t("All")} (${counts.all})`} />
<Tab value="pending" label={`${t("pending")} (${counts.pending})`} />
<Tab value="processing" label={`${t("Processing")} (${counts.processing})`} />
<Tab
value="pending_qc"
label={`${t("Waiting QC Put Away")} (${counts.pendingQc})`}
/>
<Tab value="putawayed" label={`${t("Put Awayed")} (${counts.putawayed})`} />
<Tab
value="issue"
label={`${t("Issue")} (${counts.stop + counts.cancel})`}
/>
</Tabs>

{primaryTab === "pending" && (
<Tabs
value={pendingSub}
onChange={(_, v: PendingSubTab) => setPendingSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_not_started" label={t("Picked")} />
<Tab value="not_picked_not_started" label={t("Not picked")} />
</Tabs>
)}

{primaryTab === "processing" && (
<Tabs
value={processingSub}
onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_started" label={t("Picked")} />
<Tab value="not_picked_started" label={t("Not picked")} />
</Tabs>
)}

{primaryTab === "issue" && (
<Tabs
value={issueSub}
onChange={(_, v: IssueSubTab) => setIssueSub(v)}
sx={{ mb: 2 }}
>
<Tab value="stop" label={`${t("Stop (paused)")} (${counts.stop})`} />
<Tab value="cancel" label={`${t("Cancelled")} (${counts.cancel})`} />
</Tabs>
)}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<>
<TableContainer component={Paper} sx={{ maxHeight: 480, overflow: "auto" }}>
<Table size="small" stickyHeader sx={{ minWidth: 800 }}>
<TableHead>
<TableRow>
<TableCell>{t("Job Order")}</TableCell>
<TableCell>{t("Item")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell>{t("Production Date")}</TableCell>
<TableCell>{t("Status")}</TableCell>
<TableCell align="center" sx={{ minWidth: 280 }}>
{t("Actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
{t("No data available")}
</TableCell>
</TableRow>
) : (
paginatedRows.map((row) => {
const busy = rowBusyIds.has(row.jobOrderId);
return (
<TableRow key={row.key} hover>
<TableCell>{row.jobOrderCode}</TableCell>
<TableCell>
<Typography variant="body2">
{[row.itemCode, row.itemName].filter(Boolean).join(" ")}
</Typography>
</TableCell>
<TableCell align="right">
{row.requiredQty}
{row.uom ? ` ${row.uom}` : ""}
</TableCell>
<TableCell>
{row.productionDate && dayjs(row.productionDate).isValid()
? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{row.isPaused && (
<Chip size="small" color="warning" label={t("Stop (paused)")} />
)}
<Chip
size="small"
variant="outlined"
label={bucketLabel(row)}
/>
</Stack>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={1}
justifyContent="center"
flexWrap="wrap"
useFlexGap
>
<Button
size="small"
variant="outlined"
onClick={() => onSelectProcess?.(row.jobOrderId)}
>
{t("View Details")}
</Button>
{primaryTab === "pending_qc" && row.stockInLineId != null && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("view stockin")}
</Button>
)}
{primaryTab === "putawayed" && row.stockInLineId != null && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("Put Away Detail")}
</Button>
)}
{showManageActions && !row.isCancelled && (
<Button
size="small"
variant="contained"
disabled={!canManage || busy}
onClick={() => handleComplete(row)}
startIcon={
busy ? (
<CircularProgress size={14} color="inherit" />
) : undefined
}
>
{t("Update Job Order")}
</Button>
)}
{showManageActions && !row.isCancelled && (
<Button
size="small"
variant="outlined"
color="warning"
disabled={!canManage || busy}
onClick={() => handleCancel(row)}
>
{t("Cancel Job Order")}
</Button>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={filteredRows.length}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[5, 10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
/>
</>
)}
</CardContent>
</Card>

<Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth>
<DialogTitle>{t("Confirm")}</DialogTitle>
<DialogContent>
<Typography variant="body2">{confirmMessage}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeConfirm} disabled={confirmLoading}>
{t("Cancel")}
</Button>
<Button
variant="contained"
onClick={() => void onConfirm()}
disabled={confirmLoading || !pendingAction}
>
{confirmLoading ? t("Processing...") : t("Confirm")}
</Button>
</DialogActions>
</Dialog>

<QcStockInModal
session={sessionToken}
open={qcModalOpen}
onClose={() => {
setQcModalOpen(false);
setQcModalInfo(undefined);
}}
inputDetail={qcModalInfo}
printerCombo={printerCombo}
warehouse={[]}
printSource="productionProcess"
uiMode="default"
/>
</LocalizationProvider>
);
};

export default JobOrderOpsTable;

+ 29
- 52
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Ver arquivo

@@ -1,5 +1,5 @@
"use client";
import React, { useCallback, useEffect, useState, useMemo } from "react";
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import {
Box,
Button,
@@ -23,7 +23,7 @@ import {
} from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from "react-i18next";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions";
import { fetchProductProcessesByJobOrderId , setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions";
import ProductionProcessDetail from "./ProductionProcessDetail";
import { BomCombo } from "@/app/api/bom";
import { fetchBomCombo } from "@/app/api/bom/index";
@@ -36,7 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli
import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded';
import { fetchInventories } from "@/app/api/inventory/actions";
import { InventoryResult } from "@/app/api/inventory";
import { releaseJo, startJo } from "@/app/api/jo/actions";
import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions";
import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan";
import ProcessSummaryHeader from "./ProcessSummaryHeader";
import EditIcon from "@mui/icons-material/Edit";
@@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps {
initialTabIndex?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,
@@ -276,25 +277,13 @@ const isPutAwayed = useMemo(
() => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed",
[processData?.jobOrderStatus]
);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);

const handleConfirmDeleteJobOrder = useCallback(async () => {
setDeleteLoading(true);
try {
const response = await deleteJobOrder(jobOrderId);
if (response) {
setDeleteConfirmOpen(false);
onBack();
}
} finally {
setDeleteLoading(false);
}
}, [jobOrderId, onBack]);
const cancelInFlightRef = useRef(false);

const handleConfirmCancelJobOrder = useCallback(async () => {
if (cancelInFlightRef.current) return;
cancelInFlightRef.current = true;
setCancelLoading(true);
try {
await setJobOrderHidden(jobOrderId, true);
@@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => {
onBack();
} finally {
setCancelLoading(false);
cancelInFlightRef.current = false;
}
}, [jobOrderId, onBack]);
const handleRelease = useCallback(async ( jobOrderId: number) => {
// TODO: 替换为实际的 release 调用
console.log("Release clicked for jobOrderId:", jobOrderId);
const response = await releaseJo({ id: jobOrderId })
if (response) {
//setProcessData(response.entity);
await fetchData();
const releaseInFlightRef = useRef(false);
const [isReleasing, setIsReleasing] = useState(false);

const handleRelease = useCallback(async (jobOrderId: number) => {
if (releaseInFlightRef.current) return;
releaseInFlightRef.current = true;
setIsReleasing(true);
try {
// Workbench no-hold release: defer SPL/SOL/hold until first pick assign
const response = await releaseJoForWorkbench({ id: jobOrderId });
if (response) {
await fetchData();
}
} finally {
setIsReleasing(false);
releaseInFlightRef.current = false;
}
}, [jobOrderId]);
}, [fetchData]);
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
@@ -717,21 +716,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong>
</Typography>
{fromJosave && jobOrderPlanning && (
<Button
variant="contained"
color="error"
onClick={() => setDeleteConfirmOpen(true)}
>
{t("Delete Job Order")}
</Button>
)}
{fromJosave && !jobOrderPlanning && (
{fromJosave && (
<Button
variant="contained"
color="warning"
onClick={() => setCancelConfirmOpen(true)}
disabled={isPutAwayed}
disabled={isPutAwayed || cancelLoading}
>
{t("Cancel Job Order")}
</Button>
@@ -741,8 +731,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
variant="contained"
color="primary"
onClick={() => handleRelease(jobOrderId)}
//disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"}
disabled={processData?.jobOrderStatus !== "planning"}
disabled={processData?.jobOrderStatus !== "planning" || isReleasing}
startIcon={isReleasing ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{t("Release")}
</Button>
@@ -979,19 +969,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
</DialogActions>
</Dialog>

<Dialog open={deleteConfirmOpen} onClose={() => !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{t("Confirm delete job order")}</DialogTitle>
<DialogContent>
<Typography variant="body2">{t("Delete job order confirm message")}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmOpen(false)} disabled={deleteLoading}>{t("Cancel")}</Button>
<Button variant="contained" color="error" onClick={() => void handleConfirmDeleteJobOrder()} disabled={deleteLoading}>
{deleteLoading ? <CircularProgress size={20} /> : t("Delete Job Order")}
</Button>
</DialogActions>
</Dialog>

<Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{t("Confirm cancel job order")}</DialogTitle>
<DialogContent>


+ 747
- 197
src/components/ProductionProcess/ProductionProcessList.tsx
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 29
- 70
src/components/ProductionProcess/ProductionProcessPage.tsx Ver arquivo

@@ -8,12 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal";
import ProductionProcessList, {
createDefaultProductionProcessListPersistedState,
} from "@/components/ProductionProcess/ProductionProcessList";
import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail";
import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail";
import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan";
import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard";
import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard";
import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard";
import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable";
import type { PrinterCombo } from "@/app/api/settings/printer";
import { useTranslation } from "react-i18next";

@@ -23,26 +24,20 @@ interface ProductionProcessPageProps {

const STORAGE_KEY = 'productionProcess_selectedMatchingStock';

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "productionProcess"]);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
const [selectedMatchingStock, setSelectedMatchingStock] = useState<{
jobOrderId: number;
productProcessId: number;
pickOrderId: number;
} | null>(null);
/** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */
const [tabIndex, setTabIndex] = useState(0);
/** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */
const [productionListState, setProductionListState] = useState(() => ({
...createDefaultProductionProcessListPersistedState(),
// date: "",
}));
const [waitingPutawayListState, setWaitingPutawayListState] = useState(
createDefaultProductionProcessListPersistedState,
);
const [putawayedListState, setPutawayedListState] = useState(
createDefaultProductionProcessListPersistedState,
);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const searchParams = useSearchParams();
@@ -51,22 +46,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
const [linkQcOpen, setLinkQcOpen] = useState(false);
const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null);

// Add printer selection state
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
printerCombo && printerCombo.length > 0 ? printerCombo[0] : null
);

// 从 sessionStorage 恢复状态(仅在客户端)
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// 验证数据有效性
if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') {
setSelectedMatchingStock(parsed);
console.log(" Restored selectedMatchingStock from sessionStorage:", parsed);
}
}
} catch (error) {
@@ -76,19 +67,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
}
}, []);

// 保存状态到 sessionStorage
useEffect(() => {
if (typeof window !== 'undefined') {
if (selectedMatchingStock) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock));
console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock);
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
}, [selectedMatchingStock]);

// 处理返回列表时清除存储
const handleBackFromSecondScan = useCallback(() => {
setSelectedMatchingStock(null);
if (typeof window !== 'undefined') {
@@ -102,7 +90,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

const openStockInLineIdQ = searchParams.get("openStockInLineId");

/** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */
/** Deep link: /productionProcess?openStockInLineId=… → list tab pending_qc + FG QC modal */
useEffect(() => {
if (!openStockInLineIdQ) {
setLinkQcOpen(false);
@@ -113,7 +101,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
if (!Number.isFinite(id) || id <= 0) return;
setSelectedProcessId(null);
setSelectedMatchingStock(null);
setTabIndex(1);
setTabIndex(0);
setProductionListState((prev) => ({
...prev,
pickBucket: "pending_qc",
page: 0,
}));
setLinkQcSilId(id);
setLinkQcOpen(true);
}, [openStockInLineIdQ]);
@@ -127,6 +120,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);

const listTab = String(productionListState.pickBucket || "needs_action");
const showPrinterBar = tabIndex === 0 && listTab === "pending_qc";

if (selectedMatchingStock) {
return (
<JobPickExecutionsecondscan
@@ -152,8 +148,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
return (
<>
<Box>
{/* Header section with printer selection */}
{tabIndex === 1 && (
{showPrinterBar && (
<Box sx={{
p: 1,
borderBottom: '1px solid #e0e0e0',
@@ -203,18 +198,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

<Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label={t("Production Process")} />
<Tab label={t("Waiting QC Put Away Job Orders")} />
<Tab label={t("Put Awayed Job Orders")} />
<Tab label={t("Job Order Ops Table")} />
<Tab label={t("Job Process Status Dashboard")} />
<Tab label={t("Operator KPI Dashboard")} />
<Tab label={t("Production Equipment Status Dashboard")} />
<Tab label={t("Drink Production Qty Dashboard")} />
</Tabs>

{tabIndex === 0 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={false}
disableDateFilter={false}
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
listPersistedState={productionListState}
onListPersistedStateChange={setProductionListState}
onSelectProcess={(jobOrderId) => {
@@ -234,59 +227,25 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
)}

{tabIndex === 1 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={true}
includePutaway={true}
putawayStatus="notCompleted"
listPersistedState={waitingPutawayListState}
onListPersistedStateChange={setWaitingPutawayListState}
<JobOrderOpsTable
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
onSelectProcess={(jobOrderId) => {
const id = jobOrderId ?? null;
if (id !== null) {
setSelectedProcessId(id);
}
}}
onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => {
setSelectedMatchingStock({
jobOrderId: jobOrderId || 0,
productProcessId: productProcessId || 0,
pickOrderId: pickOrderId || 0,
});
if (jobOrderId != null) setSelectedProcessId(jobOrderId);
}}
/>
)}
{tabIndex === 2 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={true}
includePutaway={true}
putawayStatus="completed"
listPersistedState={putawayedListState}
onListPersistedStateChange={setPutawayedListState}
onSelectProcess={(jobOrderId) => {
const id = jobOrderId ?? null;
if (id !== null) {
setSelectedProcessId(id);
}
}}
onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => {
setSelectedMatchingStock({
jobOrderId: jobOrderId || 0,
productProcessId: productProcessId || 0,
pickOrderId: pickOrderId || 0,
});
}}
/>

{tabIndex === 2 && (
<JobProcessStatus />
)}
{tabIndex === 3 && (
<JobProcessStatus />
<OperatorKpiDashboard />
)}
{tabIndex === 4 && (
<OperatorKpiDashboard />
<EquipmentStatusDashboard />
)}
{tabIndex === 5 && (
<EquipmentStatusDashboard />
<DrinkProductionQtyDashboard />
)}
</Box>
<QcStockInModal
@@ -303,4 +262,4 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
);
};

export default ProductionProcessPage;
export default ProductionProcessPage;

+ 48
- 1
src/config/reportConfig.ts Ver arquivo

@@ -1,3 +1,4 @@
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';

import { NEXT_PUBLIC_API_URL } from "@/config/api";
@@ -224,7 +225,7 @@ export const REPORTS: ReportDefinition[] = [
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "提料 Handler", name: "handler", type: "select", required: false,
{ label: "提料 Handler", name: "handler", type: "select", required: false,
multiple: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`,
@@ -336,4 +337,50 @@ export const REPORTS: ReportDefinition[] = [
},
],
},
{
id: "rep-016",
title: "成品出倉揀貨合規報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-user-pick-audit`,
responseType: "excel",
fields: [
{ label: "日期 Date", name: "dateStart", type: "date", required: true },
{
label: "提料人 Handler",
name: "handler",
type: "select",
required: false,
multiple: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/do-user-pick-audit-handlers`,
options: [],
},
{ label: "提票號碼", name: "ticketNo", type: "text", required: false },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false },
{
label: "樓層",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "2F", value: "2F" },
{ label: "4F", value: "4F" },
],
},
],
},
{
id: "rep-017",
title: "店鋪訂單補貨記錄",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment`,
responseType: "excel",
fields: [
// { label: "補貨日期:由 Reorder Date Start", name: "reorderDateStart", type: "date", required: false },
//{ label: "補貨日期:至 Reorder Date End", name: "reorderDateEnd", type: "date", required: false },
{ label: "店鋪訂單日期:由 Shop Order Date Start", name: "shopOrderDateStart", type: "date", required: false },
{ label: "店鋪訂單日期:至 Shop Order Date End", name: "shopOrderDateEnd", type: "date", required: false },
//{ label: "送貨日期:由 Delivered Date Start", name: "deliveredDateStart", type: "date", required: false },
//{ label: "送貨日期:至 Delivered Date End", name: "deliveredDateEnd", type: "date", required: false },
{ label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" },
],
},
]

+ 7
- 125
src/i18n/en/inventory.json Ver arquivo

@@ -1,75 +1,28 @@
{
"-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out",
"AVAILABLE": "AVAILABLE",
"Action": "Action",
"Add": "Add",
"Add entry": "Add entry",
"Add entry for items without inventory": "Add entry for items without inventory",
"Adjusted Qty": "Adjusted Qty",
"Approve": "Approve",
"Approver All": "Approver All",
"Approver search empty hint": "Set search criteria and click Search",
"ApproverAll": "ApproverAll",
"Available": "Available",
"Available Qty": "Available Qty",
"Available Qty Per Smallest Unit": "Available Qty Per Smallest Unit",
"Average unit price": "Average unit price",
"Back": "Back",
"Bad Item Handle": "Bad Item Handle",
"Bad Item Qty": "Bad Item Qty",
"Bad Item Records": "Bad Item Records",
"Balance Qty": "Balance Qty",
"Base UoM": "Base UoM",
"Batch Save Completed": "Batch Save Completed",
"Batch Save Inputted": "Batch Save Inputted",
"Batch Submit All": "Batch Submit All",
"Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors",
"Body is unavailable": "Body is unavailable",
"Bom Req. Qty": "Bom Req. Qty",
"CMB": "Consumables",
"CO": "Consumables",
"Clear selection": "Clear selection",
"Cancel": "Cancel",
"Code": "Code",
"Collapse floor sections": "Collapse sections for this floor",
"Confirm Adjustment": "Confirm Adjustment",
"Confirm create stock take for all sections?": "Confirm create stock take for all sections?",
"Confirm remove": "Confirm remove",
"Counted Qty": "Counted Qty",
"Create Stock Take for All Sections": "Create Stock Take for All Sections",
"Current Stock": "Current Stock",
"Delivery Order": "Delivery Order",
"Difference": "Difference",
"Download QR Code": "Download QR Code",
"Edit mode": "Edit mode",
"End Date": "End Date",
"Enter item code or name to search": "Enter item code or name to search",
"Expand floor sections": "Expand sections for this floor",
"Expiry Date": "Expiry Date",
"Expiry End Date": "Expiry End Date",
"Expiry Item Handle": "Expiry Item Handle",
"Expiry Item Qty": "Expiry Item Qty",
"Expiry Item Records": "Expiry Item Records",
"Expiry Qty": "Expiry Qty",
"Expiry Start Date": "Expiry Start Date",
"FG": "Finished good",
"Failed to transfer stock": "Failed to transfer stock",
"Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.",
"Filtered out": "Filtered out",
"First Qty": "First Qty",
"Handled Date": "Handled Date",
"Handler": "Handler",
"Hide Search Filters": "Hide Search Filters",
"In Qty": "In Qty",
"Input": "Input",
"Invalid QTY": "Invalid quantity",
"Inventory": "Inventory",
"Inventory Exception Management": "Inventory Exception Management",
"Item Name": "Item Name",
"Item": "Item",
"Item selected": "Item selected",
"Item-lotNo": "Item-lotNo",
"Job Order": "Job Order",
"Latest market unit price": "Latest market unit price",
"Loading": "Loading",
"Location": "Location",
"Lot No": "Lot No",
"MA": "Material",
"MI": "Miscellaneous",
@@ -77,130 +30,59 @@
"NM": "Miscellaneous / non-consumables",
"Name": "Name",
"No Data": "No Data",
"No changes to submit": "No changes to submit",
"No issues found": "No issues found",
"No data": "No data",
"No items are selected yet.": "No items are selected yet.",
"No lot no entered, will be generated by system.": "No lot no entered, will be generated by system.",
"No record found": "No record found",
"Opening Inventory": "Opening Inventory",
"Optional - system will generate": "Optional - system will generate",
"Original Qty": "Original Qty",
"Out Qty": "Out Qty",
"Perform Stock Take": "Perform Stock Take",
"Pick Order, Issue No, Item, Lot...": "Pick Order, Issue No, Item, Lot...",
"Please enter QTY and Bad QTY": "Please enter QTY and Bad QTY",
"Please scan...": "Please scan...",
"Please set at least one search criterion": "Please set at least one search criterion",
"Print": "Print",
"Print QR Code": "Print QR Code",
"Print Qty": "Print Qty",
"Print failed": "Print failed",
"Print sent": "Print sent",
"Printer": "Printer",
"Qty": "Qty",
"Qty To Be Transferred": "Qty To Be Transferred",
"Quantity exceeds available quantity": "Quantity exceeds available quantity",
"RM": "Raw material",
"Reason for adjustment": "Reason for adjustment",
"Reason for removal": "Reason for removal",
"Refresh": "Refresh",
"Remaining Qty": "Remaining Qty",
"Remarks": "Remarks",
"Remove": "Remove",
"Resolved": "Resolved",
"Review Variance": "Review Variance",
"Rows per page": "Rows per page",
"Reset": "Reset",
"SFG": "Semi-finished good",
"Sales UoM": "Sales UoM",
"Save": "Save",
"Save failed": "Save failed",
"Saved successfully": "Saved successfully",
"Search lot by QR code": "Search lot by QR code",
"Search to load lot lines": "Search to load lot lines",
"Second Qty": "Second Qty",
"Select": "Select",
"Select Section": "Select Section",
"Select Stock Take Section": "Select Stock Take Section",
"Select all sections": "Select all sections",
"Select sections placeholder": "Select one or more",
"Select stock take sections to create hint": "Choose warehouse stock take sections to start a new round (selected sections share one new round id in this batch).",
"Selected section count": "Selected: {{count}} section(s)",
"Show Search Filters": "Show Search Filters",
"Something went wrong fetching data in server.": "Something went wrong fetching data in server.",
"Start Date": "Start Date",
"Start Location": "Start Location",
"Start New Stock Take": "Start New Stock Take",
"Start Time": "Start Time",
"Stock Adjustment": "Stock Adjustment",
"Stock Record": "Stock Record",
"Stock Take Variance": "Stock Take Variance",
"Stock Transfer": "Stock Transfer",
"Stock UoM": "Stock UoM",
"Stock take adjustment confirmed! (Demo only)": "Stock take adjustment confirmed! (Demo only)",
"Stock take adjustment has been confirmed successfully!": "Stock take adjustment has been confirmed successfully!",
"Stock take qty exceeds maximum": "Stock take quantity cannot exceed 999,999,999,999",
"Stock transfer created new lot": "Stock transfer completed (created new lot line).",
"Stock transfer merged ambiguous": "Merged into the earliest available batch (multiple available lines for the same lot).",
"Stock transfer merged existing lot": "Stock transfer completed (merged into existing lot).",
"Stock transfer successful": "Stock transfer completed",
"Stop QR Scan": "Stop QR Scan",
"Submit": "Submit",
"Submit completed: {{success}} success, {{errors}} errors": "Submit completed: {{success}} success, {{errors}} errors",
"System Qty": "System Qty",
"Target Location": "Target Location",
"Total Approved": "Total Approved",
"Total Issues": "Total Issues",
"Total Item Number": "Total Item Number",
"Total Stock Takes": "Total Stock Takes",
"Total need stock take": "Total need stock take",
"Type": "Type",
"UNAVAILABLE": "UNAVAILABLE",
"Variance filter strict bounds": "Exclude boundaries (use > <)",
"View": "View",
"UoM": "UoM",
"WIP": "Work in progress",
"Waiting for Approver": "Waiting for Approver",
"Warehouse": "Warehouse",
"adj": "adj",
"approver": "approver",
"approving": "approving",
"available": "available",
"bad": "bad",
"cmb": "Consumables",
"complete": "complete",
"completed": "completed",
"completed by": "completed by",
"completed date": "completed date",
"completed remarks": "completed remarks",
"completed status": "completed status",
"consumable": "Consumable",
"consumables": "Consumables",
"dnNo": "dnNo",
"expiry": "expiry",
"fg": "Finished good",
"item": "Item",
"mat": "Raw material",
"miss": "miss",
"nm": "Miscellaneous / non-consumables",
"non-consumables": "Non-consumables",
"nor": "nor",
"not available": "not available",
"not match": "not match",
"not pass": "not pass",
"notMatch": "notMatch",
"notmatch": "notmatch",
"open": "open",
"pass": "pass",
"pending": "pending",
"productLotNo": "productLotNo",
"rejected": "rejected",
"save": "save",
"section": "section",
"sfg": "Semi-finished good",
"stockTaking": "stockTaking",
"tke": "tke",
"to": "to",
"trf": "trf",
"unavailable": "unavailable",
"variance": "variance",
"wip": "Work in progress",
"材料": "Material"
}

+ 321
- 0
src/i18n/en/itemTracing.json Ver arquivo

@@ -0,0 +1,321 @@
{
"title": "Item Tracing",
"subtitle": "Scan a lot QR code to trace the full lifecycle — stock in/out, QC, pick orders, job orders, stock take, transfers, and BOM links.",
"scanReady": "Scanner ready — scan lot label QR",
"scanning": "Scanning…",
"scanAgain": "Scan again",
"manualSearch": "Manual search",
"itemCode": "Item code",
"itemLot": "Item lot",
"lotNo": "Lot no.",
"search": "Trace",
"searching": "Tracing…",
"noResult": "No trace data. Scan a lot QR or search by item code and lot number.",
"notFound": "Lot not found or no permission to view.",
"traceError": "Unable to load trace data. Please try again or contact support.",
"scanError": "Invalid QR code. Expected lot label JSON with itemId and stockInLineId.",
"summary": "Lot summary",
"expiryDate": "Expiry",
"productionDate": "Production",
"stockInDate": "Stock in",
"totalAvailable": "Total available",
"warehouseBreakdown": "Warehouse breakdown",
"alternateLocationsTitle": "Same lot in other locations",
"alternateLocationsHint": "This lot may exist in multiple inventory records (e.g. after transfer). All locations are merged in the lifecycle graph above — click a row below to focus that warehouse in the graph.",
"traceAlternateLocation": "Focus in graph",
"focusWarehouseInGraph": "Focus in graph",
"sectionsMultiLocationHint": "Tables below merge events from the traced location and other warehouses. The warehouse column shows which inventory record each row belongs to.",
"action": "Action",
"timeline": "Movement timeline",
"flowGraph": "Lifecycle flow",
"flowLegendTime": "→ Time (later to the right)",
"flowLegendPhase": "↓ Phase (later stages below)",
"flowLegendBranch": "↔ Same day = left to right by time",
"flowLegendArrow": "Arrows only between phases",
"flowLegendPrelude": "↑ Pre-production at top",
"flowLegendTimeTip": "Horizontal axis = calendar dates, left to right",
"flowLegendPhaseTip": "Vertical axis = process phases, top to bottom in order",
"flowLegendBranchTip": "Events on the same calendar day are ordered left to right by timestamp; vertical lane still shows the process phase",
"flowLegendArrowTip": "Arrows link events in different phases to show flow",
"flowLegendPreludeTip": "FG lots: material inbound, QC (material + finished good), job pick at top",
"flowGraphPathJo": "Process order: material in → QC → putaway → pick → production / byproducts → FG in → warehouse → out → stock take",
"flowZoomIn": "Zoom in",
"flowZoomOut": "Zoom out",
"flowZoomFit": "Fit view",
"flowMinimapHide": "Hide minimap",
"flowMinimapShow": "Show minimap",
"flowZoomPanHint": "Scroll to zoom · drag to pan · click a node for details on the left · search nodes at top right",
"flowNodeDetailHint": "Click a node in the graph to view event details",
"flowGraphSearchPlaceholder": "Search nodes (doc no., lot, warehouse…)",
"flowGraphSearchNoMatch": "No matching nodes",
"flowGraphSearchMatch": "{{current}} / {{total}}",
"flowGraphSearchPrev": "Previous",
"flowGraphSearchNext": "Next",
"flowGraphSearchClear": "Clear",
"nodeDetailTitle": "Event details",
"nodeDetailClose": "Close",
"nodeExpired": "Expired",
"nodeDepleted": "Depleted",
"detailDirection": "Direction",
"categoryPurchase": "Purchase",
"categoryProduction": "Production",
"categoryOpen": "Lot open",
"categoryTerminal": "Terminal state",
"flowGraphHint": "Phases on the left, dates left to right. Same-day events are ordered left to right by time; arrows indicate cross-phase flow.",
"flowGraphHintJo": "Includes pre-production (material inbound, QC, job pick) and finished-good phases. One QC lane covers material and FG IQC.",
"phaseMaterialInbound": "Material inbound",
"phaseMaterialQc": "Material QC",
"phaseMaterialPick": "Job pick",
"phaseProduction": "Production / byproducts",
"nodeProductionStep": "Production step",
"nodeByproduct": "Byproduct lot",
"scrapQty": "Scrap qty",
"defectQty": "Defect qty",
"processOutputQty": "Process output",
"processScrapQty": "Scrap quantity",
"processDefectQty": "Defect quantity",
"equipment": "Equipment",
"processStep": "Process step",
"detailStepMaterials": "Step materials",
"detailAssignedStep": "Assigned process",
"traceByproductLot": "Trace byproduct lot",
"nodeScrap": "Scrap",
"nodeDefect": "Defect",
"nodeOpen": "Opening stock",
"nodeFail": "Pick failure",
"nodeDoOut": "Delivery outbound",
"nodeReplenishmentCreated": "Replenishment created",
"doOutboundExtra": "Add-on",
"doOutboundReplenish": "Replenishment",
"detailDoOutboundKind": "Outbound type",
"nodeDoGroup": "Delivery outbound group",
"flowDoGroupTitle": "Delivery outbound ({{count}})",
"flowDoGroupTotalQty": "Total outbound: {{qtyLabel}}",
"flowGroupCollapse": "Collapse group",
"flowGroupExpand": "Expand group",
"nodePickGroup": "Material pick group",
"flowPickGroupTitle": "Material pick · {{pickOrderCode}} ({{count}})",
"nodeReturn": "Return",
"nodeRepack": "Repack / split lot",
"traceRepackLot": "Trace related lot",
"productLotNo": "Product lot no.",
"nodeMaterialIn": "Material receipt",
"nodeJoCreated": "Job order created",
"nodeMaterialPick": "Material pick",
"nodePurchase": "Purchase",
"nodeReceipt": "Goods receipt",
"nodePutaway": "Putaway",
"nodePutawayTransfer": "Transfer inbound",
"flowLegendTitle": "How to read the graph",
"flowLegendOpen": "Graph legend",
"flowPhaseFilter": "Show phases",
"flowPhaseFilterAll": "All phases",
"flowPhaseFilterReset": "Reset",
"locationsShowAll": "▼ Show all {{count}} rows",
"locationsCollapse": "▲ Collapse (showing all {{count}})",
"exportExcel": "Export Excel",
"exportExcelTooltip": "Download a classified multi-sheet workbook for this lot genealogy",
"putawayTransferInboundDetail": "Received at target warehouse after transfer (system auto-completed; not the PO pending put-away queue)",
"putawayStatusPending": "Pending put-away",
"putawayStatusCompleted": "Put away done",
"phaseInbound": "Inbound",
"phasePurchase": "Purchase",
"phaseQc": "Quality inspection",
"phasePutaway": "Putaway",
"categoryReceipt": "Receipt",
"phaseWarehouse": "Warehouse operations",
"phaseOutbound": "Outbound / consumption",
"phaseStockTake": "Stock take",
"noTimestamp": "No date",
"nodeQcPass": "IQC passed",
"nodeQcFail": "IQC failed",
"nodeStockTake": "Stock take",
"nodeAdjustment": "Adjustment",
"nodeTransfer": "Transfer",
"tabOrigins": "Origins & receipt",
"tabQc": "Quality (IQC)",
"tabOutbound": "Outbound usage",
"tabStockTake": "Stock take",
"tabAdjustments": "Adjustments",
"tabTransfers": "Transfers",
"tabBom": "BOM trace",
"tabJoPick": "Job pick orders",
"joContext": "Job order context",
"planStart": "Planned start",
"plannedQty": "Planned qty",
"pickedQty": "Actual pick qty",
"requiredQty": "Required qty",
"pickedAt": "Picked at",
"traceMaterialLot": "Trace material lot",
"targetDate": "Target date",
"detailPickTargetDate": "Target date",
"completeDate": "Complete date",
"directionIn": "In",
"directionOut": "Out",
"bomDirection": "Trace direction",
"bomDirectionFG": "Finished good lot",
"bomDirectionMaterial": "Material lot",
"bomDirectionUnknown": "General inventory lot",
"bomUpstream": "Materials consumed (actual)",
"bomDownstream": "Finished goods produced",
"bomRecipe": "BOM recipe (theoretical)",
"noRecords": "No records",
"type": "Type",
"ref": "Doc no.",
"detailInboundRef": "Inbound SI",
"detailInboundSiNo": "Inbound SI no.",
"detailAdjustmentRef": "Adjustment #",
"detailReplenishmentCode": "Replenishment #",
"detailReason": "Reason",
"detailReturnRef": "Return #",
"detailSourceDoc": "Source doc #",
"detailPutawayBin": "Bin",
"supplier": "Supplier",
"dnNo": "DN no.",
"qty": "Qty",
"detailOrderQty": "Order qty",
"detailPutAwayQty": "Put away qty",
"detailRemainingQty": "Remaining qty",
"detailPurchaseUnit": "Purchase unit",
"detailPurchaseOrderNo": "PO number",
"detailSupplyTo": "Supply to",
"detailStockTakeRound": "Stock take round",
"detailStockTakeSection": "Stock take section",
"detailLocation": "Location",
"status": "Status",
"handler": "Handler",
"stockTaker": "First counter",
"timestamp": "Time",
"pickOrder": "Pick order",
"jobOrder": "Job order",
"deliveryOrder": "Delivery order",
"deliveryNoteCode": "Delivery note (DN)",
"ticketNo": "Ticket no.",
"variance": "Variance",
"before": "Book qty",
"after": "Accepted qty",
"approver": "Approver",
"from": "From",
"to": "To",
"material": "Material",
"Item": "Item",
"detailItemCode": "Item code",
"detailItemName": "Item name",
"finishedItem": "Finished item",
"finishedLot": "FG lot",
"materialLot": "Material lot",
"materialQty": "Actual pick qty",
"processingStatus": "Processing status",
"matchStatus": "Match status",
"fgQty": "FG qty",
"qtyPerUnit": "Required qty",
"uom": "UOM",
"remarks": "Remarks",
"detailFailCategory": "Fail category",
"detailProcessDescription": "Step description",
"qcPassed": "Passed",
"qcFailed": "Failed",
"acceptedQty": "Sample qty",
"failQty": "Defect qty",
"unqualifiedQty": "Reject qty",
"usageType": "Type",
"detailQcCriteria": "QC criteria",
"detailQcType": "QC type",
"detailQcUnknownItem": "Unnamed QC item",
"code.refType.PO": "Purchase order",
"code.refType.JO": "Job order",
"code.refType.DO": "Delivery order",
"code.refType.TKE": "Stock take",
"code.refType.TRANSFER": "Transfer",
"code.refType.ADJ": "Adjustment",
"code.refType.OPEN": "Lot open",
"code.refType.OTHER": "Other",
"code.movementType.STOCK_IN": "Stock in",
"code.movementType.STOCK_OUT": "Stock out",
"code.movementType.CONSUMABLE": "Consumption",
"code.movementType.RETURN": "Return",
"code.movementType.ADJ": "Adjustment",
"code.movementType.TKE": "Stock take",
"code.movementType.OPEN": "Lot open",
"code.movementType.IN": "In",
"code.movementType.OUT": "Out",
"code.stockInStatus.pending": "Pending",
"code.stockInStatus.qc": "Under QC",
"code.stockInStatus.escalated": "Escalated",
"code.stockInStatus.determine1": "1st determination",
"code.stockInStatus.determine2": "2nd determination",
"code.stockInStatus.determine3": "3rd determination",
"code.stockInStatus.receiving": "Receiving",
"code.stockInStatus.received": "Received, pending putaway",
"code.stockInStatus.completed": "Completed",
"code.stockInStatus.complete": "Completed",
"code.stockInStatus.partially_completed": "Partially completed",
"code.stockInStatus.rejected": "Rejected",
"code.qcType.IQC": "Incoming QC (IQC)",
"code.qcType.IPQC": "In-process QC (IPQC)",
"code.qcType.EPQC": "End-product QC (EPQC)",
"code.qcType.FQC": "Finished goods QC (FQC)",
"code.adjustmentType.ADJ": "Inventory adjustment",
"code.usageType.CONSUMABLE": "Consumption",
"code.usageType.PRODUCTION": "Production use",
"code.usageType.FG_DELIVERY": "FG delivery",
"code.usageType.MATERIAL": "Material issue",
"code.pickStatus.pending": "Pending",
"code.pickStatus.consolidated": "Consolidated",
"code.pickStatus.assigned": "Assigned",
"code.pickStatus.released": "Released",
"code.pickStatus.picking": "Picking",
"code.pickStatus.completed": "Completed",
"code.pickStatus.partially_completed": "Partially completed",
"code.processingStatus.pending": "Pending",
"code.processingStatus.completed": "Completed",
"code.processingStatus.rejected": "Rejected",
"code.matchStatus.pending": "Pending",
"code.matchStatus.scanned": "Scanned",
"code.matchStatus.completed": "Completed",
"code.lotLineStatus.available": "Available",
"code.lotLineStatus.unavailable": "Unavailable",
"code.joStatus.pending": "Pending",
"code.joStatus.completed": "Completed",
"code.joStatus.cancelled": "Cancelled",
"code.joStatus.in_progress": "In progress",
"code.joStatus.planning": "Planning",
"code.joStatus.packaging": "Packaging",
"code.joStatus.processing": "Processing",
"code.joStatus.pendingQC": "Pending QC",
"code.joStatus.storing": "Storing",
"code.joStatus.PARTIAL": "Partial",
"code.joStatus.partial": "Partial",
"continuousScanBlocked": "Finish current scan first",
"nodeJoOut": "Job order material issue",
"nodePoOut": "Purchase pick",
"code.refType.JO_PICK": "JO pick order",
"code.refType.PO_PICK": "Pick order",
"stockTakeStageCreated": "Round created",
"stockTakeStageFirstCount": "First count",
"stockTakeStageSecondCount": "Re-count",
"stockTakeStageApproverCount": "Approver count",
"stockTakeStageAccepted": "Accepted",
"stockTakeStagePending": "Pending review",
"stockTakeStageBookQty": "Book qty",
"stockTakeStageExpand": "Show lifecycle",
"stockTakeStageCollapse": "Hide lifecycle",
"stockTakeStageDetail": "{{count}} stages",
"locationHeader": "This lot exists in other locations ({{count}} total)",
"available": "Available",
"inQty": "Qty In",
"outQty": "Qty Out",
"warehouseLines": "Warehouse Lines",
"movements": "Movements",
"stockTake": "Stock Take",
"stockTakeCode": "Stock Take #",
"section": "Section",
"round": "Round",
"outbound": "Outbound",
"date": "Date",
"warehouse": "Warehouse",
"direction": "Direction",
"origins": "Origins",
"refCode": "Ref.",
"lastMove": "Last move"
}

+ 2
- 0
src/i18n/en/jo.json Ver arquivo

@@ -192,6 +192,7 @@
"Job Order Type": "Job Order Type",
"Job Order not found or has no items": "Job Order not found or has no items",
"Job Process Status Dashboard": "Job Process Status Dashboard",
"Drink Production Qty Dashboard": "Drink Production Qty Dashboard",
"Job Type": "Job Type",
"Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled",
"Job dashboard PP status: completed": "Job dashboard PP status: completed",
@@ -353,6 +354,7 @@
"Production Output Data Entry": "Production Output Data Entry",
"Production Priority": "Production Priority",
"Production Priority required!": "Production Priority required!",
"Production Qty": "Production Qty",
"Production Process": "Production Process",
"Production Process Information": "Production Process Information",
"Production Process Line Remark": "Production Process Line Remark",


+ 2
- 0
src/i18n/en/navigation.json Ver arquivo

@@ -5,6 +5,7 @@
"nav.store.purchaseOrder": "Purchase Order",
"nav.store.pickOrder": "Pick Order",
"nav.store.inventoryLedger": "View item In-out And inventory Ledger",
"nav.store.itemTracing": "Item Tracing",
"nav.store.stockTake": "Stock Take Management",
"nav.store.stockIssue": "Stock Issue",
"nav.store.putAwayScan": "Put Away Scan",
@@ -81,6 +82,7 @@
"nav.breadcrumb.schedulingDetailed": "Detail Scheduling",
"nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule",
"nav.breadcrumb.inventory": "Inventory",
"nav.breadcrumb.itemTracing": "Item Tracing",
"nav.breadcrumb.importTesting": "Import Testing",
"nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search",
"nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick",


+ 4
- 0
src/i18n/en/pickOrder.json Ver arquivo

@@ -497,6 +497,10 @@
"Tomorrow": "Tomorrow",
"packaging": "packaging",
"This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.",
"This lot UOM does not match the pick line. Please scan another lot.": "This lot UOM does not match the pick line. Please scan another lot.",
"Scanned lot UOM does not match pick line UOM": "This lot UOM does not match the pick line. Please scan another lot.",
"This lot has already been picked. Please scan another lot.": "This lot has already been picked. Please scan another lot.",
"Scanned lot is already completed or checked": "This lot has already been picked. Please scan another lot.",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.",
"Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})",
"Day After Tomorrow": "Day After Tomorrow",


+ 33
- 1
src/i18n/en/productionProcess.json Ver arquivo

@@ -63,7 +63,7 @@
"Not Started": "Not Started",
"cancelled": "Cancelled",
"in_progress": "In Progress",
"pending": "Pending",
"pending": "Awaiting production",
"stopped": "Stopped",
"Invalid Job Order Id": "Invalid Job Order Id",
"Invalid Stock In Line Id": "Invalid Stock In Line Id",
@@ -77,8 +77,26 @@
"Job Order Info": "Job Order Info",
"Job Order No.": "Job Order No.",
"Job Order and Product": "Job Order and Product",
"Issue": "Issue",
"Job Order Ops Table": "Job Order Ops Table",
"Pending (picked)": "Picked",
"Pending (not picked)": "Not picked",
"Processing (picked)": "Picked",
"Processing (not picked)": "Not picked",
"Picked": "Picked",
"Not picked": "Not picked",
"Stop (paused)": "Stop (paused)",
"Cancelled": "Cancelled",
"Reload data": "Reload data",
"Rows per page": "Rows per page",
"Processing...": "Processing...",
"Actions": "Actions",
"Job Order Production Process": "Job Order Production Process",
"Job Process Status Dashboard": "Job Process Status Dashboard",
"Drink Production Qty Dashboard": "Drink Production Qty Dashboard",
"Expand job order details": "Expand job order details",
"Collapse job order details": "Collapse job order details",
"Goods Name": "Goods Name",
"Job Type": "Job Type",
"Job process detail mode label": "Job process detail mode label",
"Job process detail: equipment": "Equipment",
@@ -136,6 +154,7 @@
"Production Output Data": "Production Output Data",
"Production Output Data Entry": "Production Output Data Entry",
"Production Priority": "Production Priority",
"Production Qty": "Production Qty",
"Production Process": "Production Process",
"Production Process Line Remark": "Production Process Line Remark",
"Production Process Steps": "Production Process Steps",
@@ -194,6 +213,16 @@
"Total Time": "Total Time",
"Total finished QC job orders": "Total finished QC job orders",
"Total job orders": "Total job orders",
"Including carried over": "Including carried over",
"Carried over from past day": "Carried over from past day",
"All unfinished": "Needs action",
"Needs action": "Needs action",
"Not picked · Not started": "Not picked · Not started",
"Picked · Not started": "Picked · Not started",
"Picked · In production": "Picked · In production",
"Not picked · In production": "Not picked · In production",
"Powder_Mixture": "Powder Mixture",
"Processing": "Processing",
"Total lines: ": "Total lines: ",
"Type": "Type",
"Unable to get user ID": "Unable to get user ID",
@@ -208,6 +237,9 @@
"View Details": "View Details",
"Wait Time": "Wait Time",
"Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders",
"Waiting QC Put Away": "Waiting QC Put Away",
"Put Awayed": "Put Awayed",
"Put Away Detail": "Put Away Detail",
"all": "All",
"drink": "Drink",
"id": "ID",


+ 3
- 0
src/i18n/en/ticketReleaseTable.json Ver arquivo

@@ -1,6 +1,7 @@
{
"Actions": "Actions",
"All Floors": "All Floors",
"All Handlers": "All Handlers",
"All Statuses": "All Statuses",
"Auto-refresh every 1 minute": "Auto-refresh every 1 minute",
"Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes",
@@ -30,9 +31,11 @@
"Revert assignment": "Revert assignment",
"Revert assignment hint": "Revert assignment hint",
"Rows per page": "Rows per page",
"Select All": "Select All",
"Select Date": "Select Date",
"Shop Name": "Shop Name",
"Status": "Status",
"Unassigned": "Unassigned",
"Store ID": "Store ID",
"Target Date": "Target Date",
"Ticket Information": "Ticket Information",


+ 72
- 190
src/i18n/zh/inventory.json Ver arquivo

@@ -1,206 +1,88 @@
{
"Inventory": "存貨",
"Action": "操作",
"Add": "新增",
"Add entry": "新增倉存",
"Add entry for items without inventory": "為無庫存貨品新增倉存",
"Adjusted Qty": "調整後倉存",
"Available Qty": "可用數量",
"CMB": "消耗品",
"CO": "消耗品",
"Cancel": "取消",
"Code": "編號",
"Name": "名稱",
"Type": "類型",
"Lot No": "批號",
"Confirm remove": "確認移除",
"Current Stock": "現有庫存",
"Difference": "差異",
"Download QR Code": "下載",
"Edit mode": "編輯模式",
"Enter item code or name to search": "輸入貨品編號或名稱以搜索",
"Expiry Date": "到期日",
"Warehouse": "倉庫",
"材料": "材料",
"consumable": "消耗品",
"Qty": "盤點數量",
"Total need stock take": "總需盤點數量",
"Waiting for Approver": "待審核數量",
"Total Approved": "已審核數量",
"mat": "原料",
"variance": "差異",
"ApproverAll": "審核員",
"Approver All": "審核員全部盤點",
"fg": "成品",
"FG": "成品",
"sfg": "半成品",
"SFG": "半成品",
"consumables": "消耗品",
"non-consumables": "非消耗品",
"item": "貨品",
"NM": "雜項及非消耗品",
"CMB": "消耗品",
"RM": "原料",
"Failed to transfer stock": "轉倉失敗",
"Failed to transfer stock. Please try again.": "轉倉失敗,請重試。",
"Inventory": "存貨",
"Item": "貨品",
"Item selected": "已選擇貨品",
"Location": "位置",
"Lot No": "批號",
"MA": "材料",
"CO": "消耗品",
"MI": "雜項",
"wip": "半成品",
"WIP": "半成品",
"cmb": "消耗品",
"nm": "雜項及非消耗品",
"available": "可用",
"unavailable": "不可用",
"tke": "盤點",
"Total Stock Takes": "總盤點數量",
"Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 失敗",
"Body is unavailable": "輸入不可用",
"Confirm create stock take for all sections?": "確認為所有區域創建盤點?",
"not available": "不可用",
"Batch Submit All": "批量提交所有",
"not match": "要求重點",
"Show Search Filters": "顯示搜索器",
"Hide Search Filters": "隱藏搜索器",
"-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉",
"Variance filter strict bounds": "不使用=",
"Filtered out": "過濾掉",
"Total Item Number": "貨品數量",
"Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量",
"Invalid QTY": "無效的數量",
"Stock take qty exceeds maximum": "盤點數量不可超過 999,999,999,999",
"Start Time": "開始時間",
"stockTaking": "盤點中",
"rejected": "已拒絕",
"miss": "缺貨",
"bad": "不良",
"expiry": "過期",
"open": "開倉",
"Bom Req. Qty": "需求數(BOM單位)",
"notmatch": "要求重點",
"pass": "已盤點",
"not pass": "不通過",
"Available": "可用",
"approving": "審核中",
"pending": "待處理",
"notMatch": "要求重點",
"Input": "輸入",
"Something went wrong fetching data in server.": "在伺服器上取得資料時發生錯誤。",
"save": "保存",
"AVAILABLE": "可用",
"View": "查看",
"approver": "審核員",
"Create Stock Take for All Sections": "為所有區域創建盤點",
"Select stock take sections to create hint": "請選擇要建立新盤點輪次的盤點區域(同一批次將共用同一輪次編號)。",
"Select sections placeholder": "可多選",
"Select all sections": "全選區域",
"Clear selection": "清除選取",
"Selected section count": "已選擇 {{count}} 個區域",
"Expand floor sections": "展開此樓層區域",
"Collapse floor sections": "收合此樓層區域",
"section": "區域",
"First Qty": "第一次盤點數量",
"Second Qty": "第二次盤點數量",
"Remarks": "備註",
"Available Qty": "可用數量",
"Sales UoM": "銷售單位",
"Stock UoM": "庫存單位",
"Available Qty Per Smallest Unit": "可用數量 (基本單位)",
"Base UoM": "基本單位",
"No items are selected yet.": "未選擇貨品",
"Item selected": "已選擇貨品",
"Delivery Order": "交貨單",
"Job Order": "工單",
"Material": "物料",
"UNAVAILABLE": "不可用",
"No issues found": "未找到問題",
"Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆",
"Approve": "審核",
"complete": "完成",
"completed": "已完成",
"completed by": "完成者",
"completed date": "完成日期",
"completed remarks": "完成備註",
"completed status": "完成狀態",
"Pick Order, Issue No, Item, Lot...": "揀貨單, 問題編號, 貨品, 批號...",
"Refresh": "刷新",
"Resolved": "已解決",
"Total Issues": "總問題數量",
"Inventory Exception Management": "存貨異常管理",
"Back": "返回",
"Confirm Adjustment": "確認調整",
"Counted Qty": "盤點數量",
"Item Name": "貨品名稱",
"Perform Stock Take": "執行盤點",
"Review Variance": "審核差異",
"Select": "選擇",
"Select Section": "選擇區域",
"Select Stock Take Section": "選擇盤點區域",
"Start New Stock Take": "開始新的盤點",
"Stock Take Variance": "盤點差異",
"Stock take adjustment confirmed! (Demo only)": "盤點調整確認!(僅演示)",
"Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!",
"System Qty": "系統數量",
"Batch Save Inputted": "批量保存已輸入",
"Batch Save Completed": "批量保存完成",
"Bad Item Handle": "不良品處理",
"Search to load lot lines": "請按搜索以載入批號",
"No changes to submit": "沒有可提交的變更",
"No record found": "沒有記錄",
"Rows per page": "每頁行數",
"Bad Item Records": "不良品處理紀錄",
"Expiry Item Handle": "過期品處理",
"Expiry Item Records": "過期品處理紀錄",
"Handled Date": "處理日期",
"Expiry Start Date": "到期日(開始)",
"Expiry End Date": "到期日(結束)",
"Bad Item Qty": "不良品數量",
"Expiry Item Qty": "過期品數量",
"Handler": "處理人",
"Quantity exceeds available quantity": "數量超過可用數量",
"Stock Record": "庫存記錄",
"Item-lotNo": "貨品-批號",
"In Qty": "入庫數量",
"Expiry Qty": "過期數量",
"Out Qty": "出庫數量",
"Balance Qty": "庫存數量",
"Start Date": "開始日期",
"End Date": "結束日期",
"Loading": "加載中",
"adj": "調整",
"nor": "正常",
"trf": "轉倉",
"Stock transfer successful": "轉倉成功",
"Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)",
"Stock transfer merged existing lot": "轉倉成功(已併入既有批號)",
"Stock transfer created new lot": "轉倉成功(已建立新批號庫存)",
"Failed to transfer stock": "轉倉失敗",
"Failed to transfer stock. Please try again.": "轉倉失敗,請重試。",
"Download QR Code": "下載",
"Print QR Code": "列印",
"Stock Transfer": "轉倉",
"Start Location": "起點倉位",
"to": "轉倉至",
"Target Location": "目標倉位",
"NM": "雜項及非消耗品",
"Name": "名稱",
"No Data": "沒有數據",
"No data": "沒有數據",
"No items are selected yet.": "未選擇貨品",
"No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。",
"Opening Inventory": "開倉",
"Optional - system will generate": "選填,系統將自動生成",
"Original Qty": "原有數量",
"Qty To Be Transferred": "待轉數量",
"Remaining Qty": "剩餘數量",
"Submit": "確認",
"Printer": "列印機",
"Print Qty": "列印數量",
"Please scan...": "請掃描...",
"Print": "列印",
"Print sent": "已送出列印",
"Print QR Code": "列印",
"Print Qty": "列印數量",
"Print failed": "列印失敗",
"Stock Adjustment": "庫存調整",
"Edit mode": "編輯模式",
"Add entry": "新增倉存",
"productLotNo": "產品批號",
"dnNo": "送貨單編號",
"Optional - system will generate": "選填,系統將自動生成",
"Add": "新增",
"Opening Inventory": "開倉",
"Print sent": "已送出列印",
"Printer": "列印機",
"Qty To Be Transferred": "待轉數量",
"RM": "原料",
"Reason for adjustment": "調整原因",
"No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。",
"Reason for removal": "移除原因",
"Confirm remove": "確認移除",
"Adjusted Qty": "調整後倉存",
"Difference": "差異",
"Action": "操作",
"Saved successfully": "儲存成功",
"Save failed": "儲存失敗",
"Remaining Qty": "剩餘數量",
"Remarks": "備註",
"Remove": "移除",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格",
"Add entry for items without inventory": "為無庫存貨品新增倉存",
"Enter item code or name to search": "輸入貨品編號或名稱以搜索",
"Current Stock": "現有庫存",
"Reset": "重置",
"SFG": "半成品",
"Save": "儲存",
"Save failed": "儲存失敗",
"Saved successfully": "儲存成功",
"Search lot by QR code": "尋找批次(掃描二維碼)",
"Please scan...": "請掃描...",
"Start Location": "起點倉位",
"Stock Adjustment": "庫存調整",
"Stock Transfer": "轉倉",
"Stock UoM": "庫存單位",
"Stock transfer created new lot": "轉倉成功(已建立新批號庫存)",
"Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)",
"Stock transfer merged existing lot": "轉倉成功(已併入既有批號)",
"Stock transfer successful": "轉倉成功",
"Stop QR Scan": "停止掃碼",
"No Data": "沒有數據",
"Please set at least one search criterion": "請至少設定一項搜索條件",
"Approver search empty hint": "請設定搜索條件後點擊搜索"
"Submit": "確認",
"Target Location": "目標倉位",
"Type": "類型",
"UoM": "單位",
"WIP": "半成品",
"Warehouse": "倉庫",
"cmb": "消耗品",
"consumable": "消耗品",
"consumables": "消耗品",
"dnNo": "送貨單編號",
"fg": "成品",
"item": "貨品",
"mat": "原料",
"nm": "雜項及非消耗品",
"non-consumables": "非消耗品",
"productLotNo": "產品批號",
"sfg": "半成品",
"to": "轉倉至",
"wip": "半成品",
"材料": "材料"
}

+ 321
- 0
src/i18n/zh/itemTracing.json Ver arquivo

@@ -0,0 +1,321 @@
{
"title": "批號追溯",
"subtitle": "掃描批號 QR 即可追溯完整生命週期:入庫、出庫、品檢、提料單、工單、盤點、轉倉及 BOM 關聯。",
"scanReady": "掃碼槍就緒 — 請掃描批號標籤 QR",
"scanning": "掃描中…",
"scanAgain": "重新掃描",
"manualSearch": "手動查詢",
"itemCode": "貨品編號",
"itemLot": "貨品批號",
"lotNo": "批號",
"search": "追溯",
"searching": "查詢中…",
"noResult": "尚無追溯資料。請掃描批號 QR,或輸入貨品編號與批號查詢。",
"notFound": "找不到批號或無權限查看。",
"traceError": "無法載入追溯資料,請稍後再試或聯絡管理員。",
"scanError": "QR 格式無效。批號標籤應包含 itemId 與 stockInLineId。",
"summary": "批號摘要",
"expiryDate": "效期",
"productionDate": "生產日期",
"stockInDate": "入庫日期",
"totalAvailable": "總可用量",
"warehouseBreakdown": "各倉庫存量",
"alternateLocationsTitle": "此批號亦存於其他位置",
"alternateLocationsHint": "同一批號可能因轉倉或拆批而有多筆庫存紀錄;各倉生命週期已合併顯示於上方流程圖,可點擊下方列聚焦該倉節點。",
"traceAlternateLocation": "於流程圖聚焦",
"focusWarehouseInGraph": "於流程圖聚焦",
"sectionsMultiLocationHint": "以下表格合併顯示目前追溯位置與其他倉位事件;倉位欄標示事件所屬庫存紀錄。",
"action": "操作",
"timeline": "異動時間軸",
"flowGraph": "生命週期流程",
"flowLegendTime": "→ 時間(愈右愈晚)",
"flowLegendPhase": "↓ 階段(愈下愈後續)",
"flowLegendBranch": "↔ 同日由左至右依時間",
"flowLegendArrow": "箭頭僅跨階段",
"flowLegendPrelude": "↑ 上方為製造前",
"flowLegendTimeTip": "橫軸 = 日期,由左至右時間推進",
"flowLegendPhaseTip": "縱軸 = 製程階段,由上至下依作業順序",
"flowLegendBranchTip": "同一天的事件依時間戳由左至右排列;縱向列仍表示製程階段",
"flowLegendArrowTip": "箭頭連接不同階段的事件,表示流程走向",
"flowLegendPreludeTip": "成品批上方為原料入庫、品檢(原料與成品共用)、工單提料",
"flowGraphPathJo": "製程順序:原料入庫 → 品檢 → 上架 → 提料 → 生產/副產品 → 成品入庫 → 倉儲 → 出庫 → 盤點",
"flowZoomIn": "放大",
"flowZoomOut": "縮小",
"flowZoomFit": "顯示全圖",
"flowMinimapHide": "隱藏縮圖",
"flowMinimapShow": "顯示縮圖",
"flowZoomPanHint": "滾輪縮放 · 拖曳平移 · 點擊節點於左側看詳情 · 右上角可搜尋節點",
"flowNodeDetailHint": "點擊圖中節點以查看事件詳情",
"flowGraphSearchPlaceholder": "搜尋節點(單號、批號、倉位…)",
"flowGraphSearchNoMatch": "無符合節點",
"flowGraphSearchMatch": "{{current}} / {{total}}",
"flowGraphSearchPrev": "上一個",
"flowGraphSearchNext": "下一個",
"flowGraphSearchClear": "清除",
"nodeDetailTitle": "事件詳情",
"nodeDetailClose": "關閉",
"nodeExpired": "已過期",
"nodeDepleted": "已用完",
"detailDirection": "方向",
"categoryPurchase": "採購",
"categoryProduction": "生產",
"categoryOpen": "開倉",
"categoryTerminal": "終態",
"flowGraphHint": "左側為生命週期階段,由左至右依日期排列;同一天的事件依時間由左至右排列,箭頭表示跨階段流程。",
"flowGraphHintJo": "成品批含製造前階段(原料入庫、品檢、工單提料)與成品階段;品檢列合併原料與成品 IQC。",
"phaseMaterialInbound": "原料入庫",
"phaseMaterialQc": "原料品檢",
"phaseMaterialPick": "工單提料",
"phaseProduction": "生產/副產品",
"nodeProductionStep": "生產步驟",
"nodeByproduct": "副產品批",
"scrapQty": "損耗量",
"defectQty": "不良量",
"processOutputQty": "工序產出",
"processScrapQty": "損耗數量",
"processDefectQty": "不良品數量",
"equipment": "設備",
"processStep": "製程步驟",
"detailStepMaterials": "步驟用料",
"detailAssignedStep": "對應製程",
"traceByproductLot": "追溯副產品批",
"nodeScrap": "損耗",
"nodeDefect": "不良品",
"nodeOpen": "開倉入庫",
"nodeFail": "揀貨異常",
"nodeDoOut": "成品出倉",
"nodeReplenishmentCreated": "建立補貨",
"doOutboundExtra": "加單",
"doOutboundReplenish": "補貨",
"detailDoOutboundKind": "出庫類型",
"nodeDoGroup": "成品出倉群組",
"flowDoGroupTitle": "成品出倉({{count}} 筆)",
"flowDoGroupTotalQty": "出倉數量共:{{qtyLabel}}",
"flowGroupCollapse": "收合群組",
"flowGroupExpand": "展開群組",
"nodePickGroup": "工單提料群組",
"flowPickGroupTitle": "工單提料 · {{pickOrderCode}}({{count}} 筆)",
"nodeReturn": "退貨",
"nodeRepack": "拆批/重包",
"traceRepackLot": "追溯關聯批",
"productLotNo": "生產批號",
"nodeMaterialIn": "原料入庫",
"nodeJoCreated": "工單建立",
"nodeMaterialPick": "工單提料",
"nodePurchase": "採購",
"nodeReceipt": "收貨",
"nodePutaway": "上架",
"nodePutawayTransfer": "轉倉入庫",
"flowLegendTitle": "如何閱讀流程圖",
"flowLegendOpen": "流程圖圖例",
"flowPhaseFilter": "顯示階段",
"flowPhaseFilterAll": "全部階段",
"flowPhaseFilterReset": "重置",
"locationsShowAll": "▼ 顯示全部 {{count}} 列",
"locationsCollapse": "▲ 收合(目前顯示全部 {{count}})",
"exportExcel": "匯出 Excel",
"exportExcelTooltip": "下載此批號分類整理後的多工作表 Excel",
"putawayTransferInboundDetail": "轉倉至目標倉完成入庫(系統自動完成,非採購「待上架」流程)",
"putawayStatusPending": "待上架",
"putawayStatusCompleted": "已上架",
"phaseInbound": "入庫階段",
"phasePurchase": "採購階段",
"phaseQc": "品檢階段",
"phasePutaway": "上架階段",
"categoryReceipt": "收貨",
"phaseWarehouse": "倉儲作業",
"phaseOutbound": "出庫耗用",
"phaseStockTake": "盤點階段",
"noTimestamp": "無日期",
"nodeQcPass": "品檢合格",
"nodeQcFail": "品檢不合格",
"nodeStockTake": "盤點",
"nodeAdjustment": "庫存調整",
"nodeTransfer": "轉倉",
"tabOrigins": "來源與入庫",
"tabQc": "品質檢驗 (IQC)",
"tabOutbound": "出庫使用",
"tabStockTake": "盤點",
"tabAdjustments": "庫存調整",
"tabTransfers": "轉倉",
"tabBom": "BOM 追溯",
"tabJoPick": "工單提料",
"joContext": "工單脈絡",
"planStart": "計劃開工",
"plannedQty": "計劃產量",
"pickedQty": "實際提料數量",
"requiredQty": "需求數量",
"pickedAt": "提料時間",
"traceMaterialLot": "追溯原材料/半成品",
"targetDate": "目標日期",
"detailPickTargetDate": "需求日期",
"completeDate": "完成日期",
"directionIn": "入",
"directionOut": "出",
"bomDirection": "追溯方向",
"bomDirectionFG": "成品批",
"bomDirectionMaterial": "原料批",
"bomDirectionUnknown": "一般庫存批",
"bomUpstream": "實際耗用原料",
"bomDownstream": "關聯成品產出",
"bomRecipe": "BOM",
"noRecords": "無紀錄",
"type": "類型",
"ref": "單號",
"detailInboundRef": "入庫單 (SI)",
"detailInboundSiNo": "入庫單編號",
"detailAdjustmentRef": "調整單號",
"detailReplenishmentCode": "補貨編號",
"detailReason": "原因",
"detailReturnRef": "退貨單號",
"detailSourceDoc": "來源單號",
"detailPutawayBin": "倉位",
"supplier": "供應商",
"dnNo": "送貨單號",
"qty": "數量",
"detailOrderQty": "訂單數量",
"detailPutAwayQty": "已上架數量",
"detailRemainingQty": "剩餘數量",
"detailPurchaseUnit": "採購單位",
"detailPurchaseOrderNo": "採購單編號",
"detailSupplyTo": "供應至",
"detailStockTakeRound": "盤點輪次",
"detailStockTakeSection": "盤點區域",
"detailLocation": "庫位",
"status": "狀態",
"handler": "經手人",
"stockTaker": "初盤人",
"timestamp": "時間",
"pickOrder": "提料單",
"jobOrder": "工單",
"deliveryOrder": "送貨單",
"deliveryNoteCode": "送貨單據號 (DN)",
"ticketNo": "提票號碼",
"variance": "差異",
"before": "帳面數量",
"after": "核准數量",
"approver": "核准人",
"from": "來源倉",
"to": "目標倉",
"material": "原料",
"Item": "貨品",
"detailItemCode": "貨品編號",
"detailItemName": "貨品名稱",
"finishedItem": "成品編號",
"finishedLot": "成品批號",
"materialLot": "原料批號",
"materialQty": "實際提料數量",
"processingStatus": "處理狀態",
"matchStatus": "對料狀態",
"fgQty": "成品入庫量",
"qtyPerUnit": "需求數量",
"uom": "單位",
"remarks": "備註",
"detailFailCategory": "異常類別",
"detailProcessDescription": "工序說明",
"qcPassed": "合格",
"qcFailed": "不合格",
"acceptedQty": "上架量",
"failQty": "不良量",
"unqualifiedQty": "不合格數",
"usageType": "類型",
"detailQcCriteria": "品檢項目",
"detailQcType": "品檢類型",
"detailQcUnknownItem": "未命名品檢項",
"code.refType.PO": "採購單",
"code.refType.JO": "工單",
"code.refType.DO": "送貨單",
"code.refType.TKE": "盤點",
"code.refType.TRANSFER": "轉倉",
"code.refType.ADJ": "調整",
"code.refType.OPEN": "開倉",
"code.refType.OTHER": "其他",
"code.movementType.STOCK_IN": "入庫",
"code.movementType.STOCK_OUT": "出庫",
"code.movementType.CONSUMABLE": "耗用出庫",
"code.movementType.RETURN": "退貨",
"code.movementType.ADJ": "調整",
"code.movementType.TKE": "盤點",
"code.movementType.OPEN": "開倉",
"code.movementType.IN": "入庫",
"code.movementType.OUT": "出庫",
"code.stockInStatus.pending": "待處理",
"code.stockInStatus.qc": "品檢中",
"code.stockInStatus.escalated": "已升級",
"code.stockInStatus.determine1": "一階判定",
"code.stockInStatus.determine2": "二階判定",
"code.stockInStatus.determine3": "三階判定",
"code.stockInStatus.receiving": "收貨中",
"code.stockInStatus.received": "已收貨待入庫",
"code.stockInStatus.completed": "已完成",
"code.stockInStatus.complete": "已完成",
"code.stockInStatus.partially_completed": "部分完成",
"code.stockInStatus.rejected": "已拒收",
"code.qcType.IQC": "來貨品檢 (IQC)",
"code.qcType.IPQC": "製程品檢 (IPQC)",
"code.qcType.EPQC": "生產後品檢 (EPQC)",
"code.qcType.FQC": "成品品檢 (FQC)",
"code.adjustmentType.ADJ": "庫存調整",
"code.usageType.CONSUMABLE": "耗用",
"code.usageType.PRODUCTION": "生產耗用",
"code.usageType.FG_DELIVERY": "成品出貨",
"code.usageType.MATERIAL": "原料提料",
"code.pickStatus.pending": "待處理",
"code.pickStatus.consolidated": "已合併",
"code.pickStatus.assigned": "已指派",
"code.pickStatus.released": "已放單",
"code.pickStatus.picking": "提料中",
"code.pickStatus.completed": "已完成",
"code.pickStatus.partially_completed": "部分完成",
"code.processingStatus.pending": "待處理",
"code.processingStatus.completed": "已完成",
"code.processingStatus.rejected": "已拒收",
"code.matchStatus.pending": "待對料",
"code.matchStatus.scanned": "已掃描",
"code.matchStatus.completed": "已完成",
"code.lotLineStatus.available": "可用",
"code.lotLineStatus.unavailable": "不可用",
"code.joStatus.pending": "待處理",
"code.joStatus.completed": "已完成",
"code.joStatus.cancelled": "已取消",
"code.joStatus.in_progress": "進行中",
"code.joStatus.planning": "規劃中",
"code.joStatus.packaging": "提料中",
"code.joStatus.processing": "生產中",
"code.joStatus.pendingQC": "待品檢",
"code.joStatus.storing": "待品檢入倉",
"code.joStatus.PARTIAL": "部分完成",
"code.joStatus.partial": "部分完成",
"continuousScanBlocked": "請先完成目前掃描",
"nodeJoOut": "工單提料",
"nodePoOut": "採購提料",
"code.refType.JO_PICK": "工單提料單",
"code.refType.PO_PICK": "提料單",
"stockTakeStageCreated": "建立盤點輪次",
"stockTakeStageFirstCount": "盤點人初盤",
"stockTakeStageSecondCount": "盤點人複盤",
"stockTakeStageApproverCount": "管理員覆盤",
"stockTakeStageAccepted": "盤點核准",
"stockTakeStagePending": "待審核",
"stockTakeStageBookQty": "帳面數量",
"stockTakeStageExpand": "展開生命週期",
"stockTakeStageCollapse": "收合生命週期",
"stockTakeStageDetail": "{{count}} 個階段",
"locationHeader": "此批號亦存於其他位置(共 {{count}} 處)",
"available": "可用量",
"inQty": "入庫量",
"outQty": "出庫量",
"warehouseLines": "各倉庫存量",
"movements": "異動記錄",
"stockTake": "盤點",
"stockTakeCode": "盤點單號",
"section": "區域",
"round": "輪次",
"outbound": "出庫使用",
"date": "日期",
"warehouse": "倉庫",
"direction": "方向",
"origins": "來源",
"refCode": "單號",
"lastMove": "最近異動"
}

+ 3
- 0
src/i18n/zh/jo.json Ver arquivo

@@ -10,6 +10,7 @@
"Actual Pick Qty": "實際提料數量",
"Add Bag": "新增包裝袋",
"Add Record": "添加記錄",
"Just Pass": "通過",
"Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中",
"Add some entries!": "請添加條目",
"All": "全部",
@@ -202,6 +203,7 @@
"Job Order Type": "工單類型",
"Job Order not found or has no items": "工單不存在或沒有物品",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"Drink Production Qty Dashboard": "儀表板 - 飲料生產量數",
"Job Type": "工單類型",
"Job dashboard PP status: cancelled": "工序已取消",
"Job dashboard PP status: completed": "工序完成",
@@ -363,6 +365,7 @@
"Production Output Data Entry": "生產輸出數據輸入",
"Production Priority": "生產優先序",
"Production Priority required!": "生產優先度必填!",
"Production Qty": "生產數量",
"Production Process": "工藝流程",
"Production Process Information": "生產流程信息",
"Production Process Line Remark": "工藝明細",


+ 2
- 0
src/i18n/zh/navigation.json Ver arquivo

@@ -21,6 +21,7 @@
"nav.breadcrumb.home": "總覽",
"nav.breadcrumb.importTesting": "匯入測試",
"nav.breadcrumb.inventory": "存貨",
"nav.breadcrumb.itemTracing": "批號追溯",
"nav.breadcrumb.joEdit": "工單詳情",
"nav.breadcrumb.joTesting": "工單測試",
"nav.breadcrumb.joWorkbench": "工單工作台",
@@ -91,6 +92,7 @@
"nav.store.doWorkbench": "成品出倉",
"nav.store.finishedGoodManagement": "成品出倉管理",
"nav.store.inventoryLedger": "查看物品出入庫及庫存日誌",
"nav.store.itemTracing": "批號追溯",
"nav.store.pickOrder": "提料單",
"nav.store.purchaseOrder": "採購單",
"nav.store.putAwayScan": "上架掃碼",


+ 19
- 14
src/i18n/zh/pickOrder.json Ver arquivo

@@ -12,7 +12,7 @@
"N/A": "不適用",
"Release Pick Orders": "放單",
"released": "已放單",
"is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的 QR 碼。",
"is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的二維碼。",
"No lot rows. Select a pick order above.": "沒有批次行。請選擇一個提料單。",
"Loading...": "載入中...",
"Suggestion success": "建議成功",
@@ -65,7 +65,7 @@
"items": "項目",
"Select Pick Order:": "選擇提料單:",
"No Stock Available": "沒有庫存",
"is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的 QR 碼。",
"is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的二維碼。",
"Start Fail": "開始失敗",
"Start PO": "開始採購訂單",
"Do you want to complete?": "確定完成嗎?",
@@ -136,7 +136,7 @@
"LotNo": "批號",
"Po Code": "採購訂單編號",
"No Warehouse": "沒有倉庫",
"Please scan warehouse qr code.": "請掃描倉庫 QR 碼。",
"Please scan warehouse qr code.": "請掃描倉庫二維碼。",

"Reject": "拒絕",
"submit": "確認提交",
@@ -270,7 +270,7 @@
"Pick order completed successfully!": "提料單完成成功!",
"Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。",
"This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。",
"Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。",
"Please finish QR code scan, QC check and pick order.": "請完成二維碼掃描、QC 檢查和提料。",
"No data available": "沒有資料",
"Please submit the pick order.": "請提交提料單。",
"Item lot to be Pick:": "批次貨品提料:",
@@ -293,13 +293,13 @@
"Original Available Qty": "原可用數",
"Remaining Available Qty": "剩餘可用數",
"Please submit pick order.": "請提交提料單。",
"Please finish QR code scan and pick order.": "請完成 QR 碼掃描和提料。",
"Please finish QR code scanand pick order.": "請完成 QR 碼掃描和提料。",
"Please finish QR code scan and pick order.": "請完成二維碼掃描和提料。",
"Please finish QR code scanand pick order.": "請完成二維碼掃描和提料。",
"First created group": "首次建立分組",
"Latest created group": "最新建立分組",
"Manual Input": "手動輸入",
"QR Code Scan for Lot": " QR 碼掃描批次",
"Processing QR code...": "處理 QR 碼...",
"QR Code Scan for Lot": "二維碼掃描批次",
"Processing QR code...": "處理二維碼...",
"The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。",
"Verified successfully!": "驗證成功!",
"Cancel": "取消",
@@ -370,7 +370,7 @@
"Scanning...":"掃描中...",
"Print DN/Label":"列印送貨單/標籤",
"Store ID":"儲存編號",
"QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。",
"QR code does not match any item in current orders.":"二維碼不符合當前訂單中的任何貨品。",
"Lot Number Mismatch":"批次號碼不符",
"The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?",
"The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。",
@@ -389,7 +389,7 @@
"Lot switch failed":"批次切換失敗",
"The system could not switch to the scanned lot. Review the lots below, then tap Confirm to retry.":"系統無法切換至掃描的批次。請核對下方批次後按「確認」重試。",
"You can also scan again: expected lot QR keeps the suggested line; scanned lot QR retries the switch.":"您也可以再掃描:掃描建議批次 QR 可保留該行;掃描欲切換批次 QR 可再次嘗試切換。",
"QR code verified.":"QR 碼驗證成功。",
"QR code verified.":"二維碼驗證成功。",
"Order Finished":"訂單完成",
"Submitted Status":"提交狀態",
"Pick Execution Record":"提料執行記錄",
@@ -507,12 +507,17 @@
"packaging": "提料中",
"No Stock Available": "沒有庫存可用",
"This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。",
"This lot UOM does not match the pick line. Please scan another lot.": "此批號單位不符,請掃描其他批號。",
"Scanned lot UOM does not match pick line UOM": "此批號單位不符,請掃描其他批號。",
"This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。",
"Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用二維碼。",
"Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})",
"Day After Tomorrow": "後日",
"Lot line is unavailable": "掃描批次不可用",
"Select Date": "請選擇日期",
"Suggest Lot No.": "推薦批號",
"Suggest Lot No.": "推薦批號/實際批號",
"Search by Shop": "搜索商店",
"Search by Truck": "搜索貨車",
"Print DN & Label": "列印提料單和送貨單標籤",
@@ -541,13 +546,13 @@
"4F ticket": "4/F 票",
"4F lane panel legend": "貨車班次 — 裝載序(未撳數/總單數)",
"Loading sequence n": "板{{n}}",
"lot QR code": "批號 QR 碼",
"lot QR code": "批號二維碼",
"label Printer" : "標籤打印機",
"A4 Printer" : "A4 打印機",
"Loading Sequence": "裝載序",
"Ticket No": "提票號碼",
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "掃描的庫存批行為「不可用」,無法換批或綁定;揀貨行未更新。",
"is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的 QR 碼。",
"is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的二維碼。",
"Lot switch failed; pick line was not marked as checked.": "換批失敗;揀貨行未標為已核對。",
"Lot confirmation failed. Please try again.": "確認批號失敗,請重試。",
"Powder Mixture": "箱料粉",


+ 33
- 1
src/i18n/zh/productionProcess.json Ver arquivo

@@ -68,7 +68,7 @@
"Not Started": "未開始",
"cancelled": "已取消",
"in_progress": "進行中",
"pending": "待處理",
"pending": "待生產",
"stopped": "已停止",
"Invalid Job Order Id": "無效工單編號",
"Invalid Stock In Line Id": "無效庫存行ID",
@@ -82,8 +82,26 @@
"Job Order Info": "工單信息",
"Job Order No.": "工單編號",
"Job Order and Product": "工單及貨品",
"Issue": "異常",
"Job Order Ops Table": "查看工單流程情況",
"Pending (picked)": "已提料",
"Pending (not picked)": "未提料",
"Processing (picked)": "已提料",
"Processing (not picked)": "未提料",
"Picked": "已提料",
"Not picked": "未提料",
"Stop (paused)": "暫停中",
"Cancelled": "已取消",
"Reload data": "重新載入",
"Rows per page": "每頁列數",
"Processing...": "處理中...",
"Actions": "操作",
"Job Order Production Process": "工單生產流程",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"Drink Production Qty Dashboard": "儀表板 - 飲料生產量數",
"Expand job order details": "展開工單明細",
"Collapse job order details": "收合工單明細",
"Goods Name": "貨品名稱",
"Job Type": "工單類型",
"Job process detail mode label": "工序格顯示",
"Job process detail: equipment": "設備",
@@ -141,6 +159,7 @@
"Production Output Data": "生產輸出數據",
"Production Output Data Entry": "生產輸出數據輸入",
"Production Priority": "生產優先序",
"Production Qty": "生產數量",
"Production Process": "工藝流程",
"Production Process Line Remark": "工藝明細",
"Production Process Steps": "生產流程步驟",
@@ -199,6 +218,16 @@
"Total Time": "總時間",
"Total finished QC job orders": "總完成QC工單數量",
"Total job orders": "總工單數量",
"Including carried over": "含過去轉來",
"Carried over from past day": "過去轉來的工單",
"All unfinished": "需處理",
"Needs action": "需處理",
"Not picked · Not started": "未提料 · 未開工",
"Picked · Not started": "已提料 · 未開工",
"Picked · In production": "已提料 · 未完成生產",
"Not picked · In production": "未提料 · 未完成生產",
"Powder_Mixture": "箱料粉",
"Processing": "生產中",
"Total lines: ": "總數量:",
"Type": "類型",
"Unable to get user ID": "無法獲取用戶ID",
@@ -213,6 +242,9 @@
"View Details": "查看詳情",
"Wait Time": "等待時間",
"Waiting QC Put Away Job Orders": "待QC上架工單",
"Waiting QC Put Away": "待QC上架",
"Put Awayed": "已上架",
"Put Away Detail": "上架詳情",
"all": "全部",
"drink": "飲料",
"id": "ID",


+ 3
- 0
src/i18n/zh/ticketReleaseTable.json Ver arquivo

@@ -25,6 +25,9 @@
"Released Time": "開始時間",
"Completed Time": "完成時間",
"Handler Name": "負責員工",
"All Handlers": "所有員工",
"Select All": "全選",
"Unassigned": "未分配",
"Number of FG Items (Order Item(s) Count)": "訂單項目數量",
"No data available": "沒有資料",
"Rows per page": "每頁行數",


+ 57
- 0
src/utils/traceDoOutboundExtra.ts Ver arquivo

@@ -0,0 +1,57 @@
const parseTicketTypeLetter = (ticketNo?: string | null): string | undefined => {
const parts = (ticketNo ?? "").trim().split("-");
return parts[1]?.toUpperCase();
};

export const isTiETicketNo = (ticketNo?: string | null): boolean => {
const tn = (ticketNo ?? "").trim().toUpperCase();
return parseTicketTypeLetter(tn) === "E" || tn.startsWith("TI-E-");
};

export const isTiMTicketNo = (ticketNo?: string | null): boolean =>
(ticketNo ?? "").trim().toUpperCase().startsWith("TI-M-");

const isExtraReleaseType = (releaseType?: string | null): boolean => {
const rt = (releaseType ?? "").trim().toLowerCase();
return rt === "isextra" || rt === "isextrabatch" || rt === "isextrasingle";
};

export type TraceDoOutboundExtraSource = {
isExtra?: boolean;
ticketNo?: string | null;
consoCode?: string | null;
releaseType?: string | null;
deliveryOrderIsExtra?: boolean;
deliveryOrderPickOrderId?: number | null;
relationshipId?: number | null;
};

/**
* Trace 加單 chip rules (aligned with DeliveryOrderService.isExtraDeliveryTicket):
* - TI-E / conso TI-E- → extra
* - TI-M → per DO isExtra, conso TI-E-, or relationshipId lineage (≠ own dop id)
* - Other → releaseType / API isExtra
*/
export const resolveTraceDoOutboundIsExtra = (
source: TraceDoOutboundExtraSource,
): boolean => {
if (source.isExtra === true) return true;

const ticket = source.ticketNo?.trim() || "";
const conso = source.consoCode?.trim() || "";
const consoIsTiE = isTiETicketNo(conso);
const deliveryOrderIsExtra = source.deliveryOrderIsExtra === true;

if (isTiMTicketNo(ticket)) {
if (deliveryOrderIsExtra) return true;
if (consoIsTiE) return true;
const dopId = source.deliveryOrderPickOrderId ?? null;
const relationshipId = source.relationshipId ?? null;
if (dopId != null && relationshipId != null && relationshipId !== dopId) return true;
return false;
}

if (isTiETicketNo(ticket) || consoIsTiE) return true;
if (isExtraReleaseType(source.releaseType) && !isTiMTicketNo(ticket)) return true;
return deliveryOrderIsExtra;
};

Carregando…
Cancelar
Salvar