diff --git a/src/app/(main)/itemTracing/page.tsx b/src/app/(main)/itemTracing/page.tsx
new file mode 100644
index 0000000..4c3c8e4
--- /dev/null
+++ b/src/app/(main)/itemTracing/page.tsx
@@ -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 (
+ <>
+
+
+ {t("title")}
+
+
+
+ }>
+
+
+
+ >
+ );
+};
+
+export default ItemTracingPage;
diff --git a/src/app/api/itemTracing/actions.ts b/src/app/api/itemTracing/actions.ts
new file mode 100644
index 0000000..91be59c
--- /dev/null
+++ b/src/app/api/itemTracing/actions.ts
@@ -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 => {
+ const query = convertObjToURLSearchParams(params as Record);
+ const json = await serverFetchJson(
+ `${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.
+ */
diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts
new file mode 100644
index 0000000..2ffe85d
--- /dev/null
+++ b/src/app/api/itemTracing/index.ts
@@ -0,0 +1,493 @@
+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;
+ 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;
+ 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;
+}
+
+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;
+}
diff --git a/src/app/api/itemTracing/schema.ts b/src/app/api/itemTracing/schema.ts
new file mode 100644
index 0000000..edf0774
--- /dev/null
+++ b/src/app/api/itemTracing/schema.ts
@@ -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> = 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> = 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;
+
+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).sort().forEach((key) => {
+ const next = prefix ? `${prefix}.${key}` : key;
+ paths.push(...collectObjectPaths((obj as Record)[key], next, depth + 1, maxDepth));
+ });
+ return paths;
+};
diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts
index 21f5506..cd58bae 100644
--- a/src/app/api/jo/actions.ts
+++ b/src/app/api/jo/actions.ts
@@ -1203,8 +1203,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());
diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts
index 67e9ae4..aa44585 100644
--- a/src/app/utils/fetchUtil.ts
+++ b/src/app/utils/fetchUtil.ts
@@ -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);
diff --git a/src/app/utils/serverFetchError.ts b/src/app/utils/serverFetchError.ts
new file mode 100644
index 0000000..d6dbc67
--- /dev/null
+++ b/src/app/utils/serverFetchError.ts
@@ -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;
+};
diff --git a/src/authorities.ts b/src/authorities.ts
index 57e4423..1107afd 100644
--- a/src/authorities.ts
+++ b/src/authorities.ts
@@ -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",
diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx
index f012cd6..20e30a7 100644
--- a/src/components/Breadcrumb/Breadcrumb.tsx
+++ b/src/components/Breadcrumb/Breadcrumb.tsx
@@ -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",
diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx
index 3e863ac..05c24a8 100644
--- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx
+++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx
@@ -147,8 +147,8 @@ const DoWorkbenchTabsInner: React.FC = ({ 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 deep-link for Finished Good Record tabs */
+ if (newTab !== 2 && newTab !== 3) {
params.delete("ticketNo");
params.delete("targetDate");
}
@@ -391,10 +391,13 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
index 3465d0e..bec0322 100644
--- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
+++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
@@ -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,
@@ -507,6 +507,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({
[],
);
+ const initialDetailOpenedRef = useRef(false);
+ useEffect(() => {
+ 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);
+ }, [records, loading, initialTicketNo, handleDetailClick]);
+
const handleBackToList = useCallback(() => {
setShowDetailView(false);
setSelectedRecord(null);
diff --git a/src/components/ItemTracing/ItemTracing.tsx b/src/components/ItemTracing/ItemTracing.tsx
new file mode 100644
index 0000000..ea8e637
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracing.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [focusWarehouse, setFocusWarehouse] =
+ useState(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 (
+
+
+ {t("subtitle")}
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {error && !loading && {error}}
+
+ {!loading && !data && !error && (
+ {t("noResult")}
+ )}
+
+ {data && !loading && (
+ <>
+ setFocusWarehouse(request)}
+ onExportExcel={compiledGraph ? handleExportExcel : undefined}
+ />
+ {compiledGraph && (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+};
+
+export default ItemTracing;
diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx
new file mode 100644
index 0000000..2aabc2d
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingDocLink.tsx
@@ -0,0 +1,90 @@
+"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. 5 | v1.0.0 | 2026-07-14 */
+const ItemTracingDocLink: React.FC = ({
+ 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);
+ 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);
+ 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);
+ href = `/doworkbench?${params.toString()}`;
+ } else {
+ return <>{code}>;
+ }
+ } else {
+ return <>{code}>;
+ }
+
+ return (
+
+ {code}
+
+ );
+};
+
+export default ItemTracingDocLink;
diff --git a/src/components/ItemTracing/ItemTracingFlowGraph.tsx b/src/components/ItemTracing/ItemTracingFlowGraph.tsx
new file mode 100644
index 0000000..b903af8
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingFlowGraph.tsx
@@ -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 = ({
+ data,
+ compiledGraph,
+ onTrace,
+ focusWarehouse,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const { fitView } = useReactFlow();
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [showMinimap, setShowMinimap] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeMatchIndex, setActiveMatchIndex] = useState(0);
+ const [legendAnchor, setLegendAnchor] = useState(null);
+ const [hiddenPhases, setHiddenPhases] = useState>(
+ () => new Set(),
+ );
+ const [collapsedGroupIds, setCollapsedGroupIds] = useState>(
+ () => 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([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();
+ 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();
+ 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) => {
+ 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 (
+
+ {t("noRecords")}
+
+ );
+ }
+
+ return (
+
+
+
+ {t("flowGraph")}
+
+
+ setLegendAnchor(e.currentTarget)}
+ >
+
+
+
+
+
+
+ {hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")}
+ {" · "}
+ {t("flowZoomPanHint")}
+
+
+
+
+ {t("flowPhaseFilter")}
+
+
+ {layout.phaseOrder.map((phase) => {
+ const active = phaseVisible(phase);
+ const chipColor = phaseChipColor(phase);
+ return (
+ 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,
+ },
+ }}
+ />
+ );
+ })}
+ }
+ 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)",
+ },
+ }}
+ />
+
+
+
+ setLegendAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ transformOrigin={{ vertical: "top", horizontal: "right" }}
+ >
+
+
+ {t("flowLegendTitle")}
+
+
+ {LEGEND_ROWS.map(({ key, tipKey }) => (
+
+
+ {t(key)}
+
+
+ {t(tipKey)}
+
+
+ ))}
+ {hasJoPrelude && (
+
+ {t("flowGraphPathJo")}
+
+ )}
+
+ {t("flowNodeDetailHint")}
+
+
+
+
+
+
+ {selectedNode && (
+
+ setSelectedNode(null)}
+ />
+
+ )}
+
+ 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%" }}
+ >
+
+
+
+
+
+
+
+ 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" },
+ }}
+ >
+
+
+
+
+
+
+
+
+ {showMinimap && (
+ {
+ 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 }}
+ />
+ )}
+
+
+
+
+ );
+};
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.0 | 2026-07-14 */
+const ItemTracingFlowGraph: React.FC = (props) => (
+
+
+
+);
+
+export default ItemTracingFlowGraph;
diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
new file mode 100644
index 0000000..169b0eb
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
@@ -0,0 +1,158 @@
+"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. 4 | v1.0.0 | 2026-07-14 */
+const ItemTracingFlowGraphSearch: React.FC = ({
+ 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 (
+
+ onQueryChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: hasQuery ? (
+
+
+
+
+
+
+
+ ) : undefined,
+ }}
+ sx={{
+ "& .MuiOutlinedInput-root": {
+ justifyContent: hasQuery ? "flex-start" : "center",
+ },
+ "& .MuiOutlinedInput-input": {
+ textAlign: hasQuery ? "left" : "center",
+ color: "text.secondary",
+ ...(hasQuery
+ ? {}
+ : {
+ flex: "0 1 auto",
+ width: "auto",
+ maxWidth: "90%",
+ }),
+ },
+ "& .MuiOutlinedInput-input::placeholder": {
+ color: "text.secondary",
+ opacity: 1,
+ textAlign: "center",
+ },
+ ...(!hasQuery
+ ? {
+ "& .MuiInputAdornment-positionStart": {
+ marginRight: 0.75,
+ },
+ }
+ : {}),
+ }}
+ />
+ {hasQuery && (
+
+ 0 ? "text.secondary" : "error"} noWrap>
+ {matchLabel}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ItemTracingFlowGraphSearch;
diff --git a/src/components/ItemTracing/ItemTracingLoading.tsx b/src/components/ItemTracing/ItemTracingLoading.tsx
new file mode 100644
index 0000000..e8834d6
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLoading.tsx
@@ -0,0 +1,11 @@
+import { Skeleton, Stack } from "@mui/material";
+
+const ItemTracingLoading: React.FC = () => (
+
+
+
+
+
+);
+
+export default ItemTracingLoading;
diff --git a/src/components/ItemTracing/ItemTracingLocations.tsx b/src/components/ItemTracing/ItemTracingLocations.tsx
new file mode 100644
index 0000000..d768102
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLocations.tsx
@@ -0,0 +1,790 @@
+"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({
+ title,
+ rows,
+ columns,
+ getRowKey,
+}: {
+ title: string;
+ rows: T[];
+ columns: FilterableColumnDef[];
+ getRowKey: (row: T, index: number) => string;
+}) {
+ const { t } = useTranslation("itemTracing");
+ const [showAll, setShowAll] = useState(false);
+
+ return (
+
+
+ {title} ({rows.length})
+
+
+ setShowAll((v) => !v)}
+ showAllLabel={(count) => t("locationsShowAll", { count })}
+ collapseLabel={(count) => t("locationsCollapse", { count })}
+ />
+
+
+ );
+}
+
+/** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */
+const ItemTracingLocations: React.FC = ({
+ locationBlocks,
+ onFocusWarehouse,
+}) => {
+ const { t } = useTranslation("itemTracing");
+ const tr = createTraceLabelTranslator(t);
+
+ if (!locationBlocks || locationBlocks.length === 0) return null;
+
+ return (
+
+
+
+
+ {t("locationHeader", { count: locationBlocks.length })}
+
+
+
+
+ {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 (
+
+ }>
+
+
+ {block.warehouseLines
+ .map((l) => l.warehouseCode)
+ .filter(Boolean)
+ .join(" / ") || `Lot #${block.inventoryLotId}`}
+
+ {block.itemName && (
+
+ {block.itemCode} ??{block.itemName}
+
+ )}
+ 0 ? "success" : "default"}
+ variant="outlined"
+ />
+
+ {t("inQty")}: {totalIn} / {t("outQty")}: {totalOut}
+
+ {block.movements.length > 0 && (
+
+ )}
+ {lastMovement?.timestamp && (
+
+ {t("lastMove")}: {lastMovement.timestamp}
+
+ )}
+ {stockTakeVariance !== 0 && (
+
+ )}
+ {onFocusWarehouse ? (
+ {
+ e.stopPropagation();
+ onFocusWarehouse({
+ inventoryLotId: block.inventoryLotId,
+ warehouseCode: blockWarehouseCode(block),
+ });
+ }}
+ sx={{ ml: "auto" }}
+ />
+ ) : null}
+
+
+
+
+
+ {block.warehouseLines.length > 0 && (
+ 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 && (
+ String(o.stockInLineId)}
+ columns={[
+ {
+ key: "type",
+ label: t("type"),
+ value: (o) => o.type,
+ cell: (o) => (
+
+ ),
+ },
+ {
+ 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 && (
+ `${qc.stockInLineId}-${qc.created}`}
+ columns={[
+ {
+ key: "qcPassed",
+ label: t("qcPassed"),
+ value: (qc) =>
+ qc.qcPassed ? t("qcPassed") : t("qcFailed"),
+ cell: (qc) => (
+
+ ),
+ },
+ {
+ 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 && (
+ 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 && (
+ `${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 && (
+ `${m.refCode}-${m.timestamp}-${idx}`}
+ columns={[
+ {
+ key: "direction",
+ label: t("direction"),
+ value: (m) => m.direction,
+ cell: (m) => (
+
+ ),
+ },
+ {
+ 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 && (
+ `${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) => (
+
+ ),
+ },
+ {
+ 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 && (
+ `${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 && (
+
+ `${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) => (
+
+ ),
+ },
+ {
+ 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 && (
+ `${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 && (
+ `${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 && (
+ 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 && (
+ `${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 && (
+ 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,
+ },
+ ]}
+ />
+ )}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ItemTracingLocations;
diff --git a/src/components/ItemTracing/ItemTracingLotTraceLink.tsx b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx
new file mode 100644
index 0000000..840be42
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx
@@ -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 = ({
+ label,
+ lotNo,
+ itemCode,
+ stockInLineId,
+ stopPropagation = false,
+ variant = "caption",
+ sx,
+}) => {
+ if (!lotNo?.trim() && stockInLineId == null) return null;
+
+ return (
+
+ {label}
+
+ );
+};
+
+export default ItemTracingLotTraceLink;
diff --git a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
new file mode 100644
index 0000000..3d22d7a
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
@@ -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. 4 | v1.0.0 | 2026-07-14 */
+const ItemTracingNodeDetailPanel: React.FC = ({ 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 (
+
+
+
+
+
+ {t("nodeDetailTitle")}
+
+ {node.categoryLabel && (
+
+ )}
+
+ {onClose && (
+
+
+
+ )}
+
+
+
+
+
+ {hasDocLink ? (
+
+ ) : (
+ node.title
+ )}
+
+ {node.subtitle && (
+
+ {node.subtitle}
+
+ )}
+
+
+
+ {node.details.map((row, i) => (
+
+
+ {row.label}
+
+
+ {row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? (
+
+ {row.qcCriteriaItems.map((item, idx) => (
+
+
+
+ {item.name}
+
+
+ {!item.passed && item.failQty != null && item.failQty > 0 && (
+
+ {t("failQty")}: {formatQty(item.failQty, node.uom)}
+
+ )}
+
+ }
+ secondary={
+ item.description ? (
+
+ {item.description}
+
+ ) : null
+ }
+ />
+
+ ))}
+
+ ) : row.linkKind && row.linkCode ? (
+
+ ) : (
+
+ {row.value || "—"}
+
+ )}
+
+
+ ))}
+
+
+
+ {node.traceLotNo ? (
+
+
+
+ ) : null}
+
+ {lifecycleStages.length > 0 && (
+ <>
+
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })}
+
+
+ >
+ )}
+
+ {node.meta && (
+ <>
+
+
+ {node.meta}
+
+ >
+ )}
+
+
+ );
+};
+
+export default ItemTracingNodeDetailPanel;
diff --git a/src/components/ItemTracing/ItemTracingScanBar.tsx b/src/components/ItemTracing/ItemTracingScanBar.tsx
new file mode 100644
index 0000000..abcceb2
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingScanBar.tsx
@@ -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. 1 | v1.0.0 | 2026-07-14 */
+const ItemTracingScanBar: React.FC = ({
+ 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(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 (
+
+
+
+ }
+ onClick={scanMode === "wedge" ? stopWedgeScan : startWedgeScan}
+ disabled={loading}
+ >
+ {scanMode === "wedge" ? t("scanning") : t("scanAgain")}
+
+ {lastLotNo && (
+
+ )}
+
+
+ {scanError && (
+ setScanError(null)}>
+ {scanError}
+
+ )}
+
+ {scanMode === "wedge" && (
+
+ {t("scanReady")}
+
+ )}
+
+ {t("manualSearch")}
+
+ setItemCode(e.target.value)}
+ disabled={loading}
+ fullWidth
+ />
+ setLotNo(e.target.value)}
+ disabled={loading}
+ fullWidth
+ />
+ }
+ onClick={handleManualSearch}
+ disabled={loading || !itemCode.trim() || !lotNo.trim()}
+ sx={{ minWidth: 120 }}
+ >
+ {loading ? t("searching") : t("search")}
+
+
+
+
+ );
+};
+
+export default ItemTracingScanBar;
diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx
new file mode 100644
index 0000000..40df2d6
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingSections.tsx
@@ -0,0 +1,915 @@
+"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. 6 | v1.0.0 | 2026-07-14 */
+const ItemTracingSections: React.FC = ({ 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; rejectedWhen?: Array },
+ ): 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 (
+
+ );
+ }
+ if (isChecked) {
+ return (
+
+ );
+ }
+ return ;
+ };
+
+ 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" ? (
+
+ ) : o.type === "JO" ? (
+
+ ) : (
+ 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) => (
+
+ ),
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (u) => u.jobOrderCode || "—",
+ cell: (u) => (
+
+ ),
+ },
+ {
+ 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: "ref",
+ label: t("ref"),
+ value: (row) => row.transferCode,
+ },
+ {
+ key: "timestamp",
+ label: t("timestamp"),
+ value: (row) => row.timestamp ?? "—",
+ },
+ );
+ return cols;
+ }, [multiLocation, t, stockUom]);
+
+ type JoMaterialInput = NonNullable["materialInputs"][number];
+ type BomUpstream = (typeof bomTrace.upstream)[number];
+
+ const bomUpstreamJoCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (m) => m.jobOrderCode || "—",
+ cell: (m) => (
+
+ ),
+ },
+ { key: "material", label: t("material"), value: (m) => m.materialItemCode },
+ { key: "materialLot", label: t("materialLot"), value: (m) => m.materialLotNo },
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (m) => m.pickOrderCode || m.consoCode || "—",
+ cell: (m) =>
+ m.pickOrderCode ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ {
+ 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 ? (
+
+ onTrace({
+ lotNo: m.materialLotNo,
+ itemCode: m.materialItemCode,
+ })
+ }
+ >
+ {t("traceMaterialLot")}
+
+ ) : (
+ "—"
+ ),
+ },
+ ], [t, tr, stockUom, onTrace]);
+
+ const bomUpstreamCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "jobOrder",
+ label: t("jobOrder"),
+ value: (u) => u.jobOrderCode || "—",
+ cell: (u) => (
+
+ ),
+ },
+ { key: "material", label: t("material"), value: (u) => u.materialItemCode },
+ { key: "materialLot", label: t("materialLot"), 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) => (
+
+ ),
+ },
+ { key: "finishedItem", label: t("finishedItem"), value: (d) => d.finishedItemCode },
+ { key: "finishedLot", label: t("finishedLot"), 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("material"), value: (r) => r.materialItemCode },
+ {
+ key: "materialName",
+ label: `${t("material")} name`,
+ 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[] => [
+ {
+ key: "pickOrder",
+ label: t("pickOrder"),
+ value: (row) => row.pickOrderCode || row.consoCode || "—",
+ cell: (row) =>
+ row.pickOrderCode ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ { key: "material", label: t("material"), value: (row) => row.materialItemCode },
+ {
+ key: "lotNo",
+ label: t("lotNo"),
+ value: (row) => row.materialLotNo || "—",
+ cell: (row) =>
+ row.materialLotNo ? (
+
+ ) : (
+ "—"
+ ),
+ },
+ {
+ 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["pickOrders"][number]["lines"][number];
+ const joPickLineCols = useMemo((): FilterableColumnDef[] => [
+ {
+ key: "material",
+ label: t("material"),
+ 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) => line.status },
+ ], [t, stockUom]);
+
+ return (
+
+ setTab(v)}
+ variant="scrollable"
+ scrollButtons="auto"
+ >
+
+
+
+
+
+
+
+ {joPrelude && }
+
+
+ {multiLocation && (
+
+ {t("sectionsMultiLocationHint")}
+
+ )}
+ {activeTab === "origins" && (
+ `${o.inventoryLotId}-${o.stockInLineId}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "qc" && (
+ q.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "outbound" && (
+ `${u.inventoryLotId}-${u.stockOutLineId}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "stockTake" && (
+ e.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "adjustments" && (
+ a.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "transfers" && (
+ row.rowKey}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+ {activeTab === "bom" && (
+
+
+ {t("bomDirection")}: {bomDirectionLabel}
+
+
+
+ {t("bomUpstream")}
+
+
+ {useJoMaterialInputs ? (
+ `up-jo-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+ ) : (
+ `up-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+ )}
+
+
+
+ {t("bomDownstream")}
+
+
+ `down-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+
+
+
+ {t("bomRecipe")}
+
+ `recipe-${i}`}
+ emptyLabel={t("noRecords")}
+ />
+
+ )}
+
+ {activeTab === "joPick" && joPrelude && (
+
+ {presentationRows.joPicks.length > 0 ? (
+ row.id}
+ emptyLabel={t("noRecords")}
+ />
+ ) : joPrelude.pickOrders.length === 0 ? (
+
+ {t("noRecords")}
+
+ ) : (
+ joPrelude.pickOrders.map((po) => (
+
+
+
+ {" · "}
+
+
+
+ {[
+ po.targetDate && `${t("targetDate")}: ${po.targetDate}`,
+ po.completeDate && `${t("completeDate")}: ${po.completeDate}`,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+ String(line.pickOrderLineId)}
+ emptyLabel={t("noRecords")}
+ />
+
+ ))
+ )}
+
+ )}
+
+
+ );
+};
+
+export default ItemTracingSections;
diff --git a/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
new file mode 100644
index 0000000..af3b3aa
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
@@ -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 = ({ stages, uom = "" }) => {
+ const { t } = useTranslation("itemTracing");
+
+ if (!stages.length) return null;
+
+ return (
+
+ {stages.map((stage, idx) => {
+ const isLast = idx === stages.length - 1;
+ return (
+
+
+
+ {!isLast && (
+
+ )}
+
+
+
+ {t(stage.label)}
+
+ {stage.isActive ? (
+
+ {[
+ 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(" · ") || "—"}
+
+ ) : (
+
+ —
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
+
+export default ItemTracingStockTakeLifecycle;
diff --git a/src/components/ItemTracing/ItemTracingSummary.tsx b/src/components/ItemTracing/ItemTracingSummary.tsx
new file mode 100644
index 0000000..bfc8299
--- /dev/null
+++ b/src/components/ItemTracing/ItemTracingSummary.tsx
@@ -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. 2 | v1.0.0 | 2026-07-14 */
+const ItemTracingSummary: React.FC = ({
+ 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 (
+
+
+
+ {t("summary")}
+
+ {onExportExcel && (
+
+ )}
+
+
+
+
+ {lot.lotNo || "—"}
+
+
+ {lot.itemCode} · {lot.itemName}
+
+
+
+
+
+
+
+ {t("expiryDate")}
+
+ {lot.expiryDate ?? "—"}
+
+
+
+ {t("productionDate")}
+
+ {lot.productionDate ?? "—"}
+
+
+
+ {t("stockInDate")}
+
+ {lot.stockInDate ?? "—"}
+
+
+
+ {t("uom")}
+
+ {lot.uom || "—"}
+
+
+ {joPrelude && (
+
+
+ {t("joContext")}
+
+
+
+
+ {t("jobOrder")}
+
+
+ {joPrelude.jobOrder.jobOrderId != null ? (
+
+ ) : (
+ joPrelude.jobOrder.jobOrderCode || "—"
+ )}
+
+
+
+
+ {t("status")}
+
+
+ {tr.joStatus(joPrelude.jobOrder.status)}
+
+
+
+
+ {t("planStart")}
+
+
+ {joPrelude.jobOrder.planStart ?? "—"}
+
+
+
+
+ {t("plannedQty")}
+
+
+ {formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)}
+
+
+
+
+ )}
+ {combinedWarehouseRows.length > 0 && (
+
+
+
+
+ {t("warehouse")}
+ {t("inQty")}
+ {t("outQty")}
+ {t("available")}
+ {t("status")}
+ {onFocusWarehouse && (
+ {t("action")}
+ )}
+
+
+
+ {combinedWarehouseRows.map((w) => (
+
+ {w.warehouseCode || "—"}
+
+ {formatQty(w.inQty, lot.uom)}
+
+
+ {formatQty(w.outQty, lot.uom)}
+
+
+ {formatQty(w.availableQty, lot.uom)}
+
+
+ {w.status ? (
+
+ ) : (
+ "—"
+ )}
+
+ {onFocusWarehouse && (
+
+ {w.warehouseCode?.trim() ? (
+
+ ) : (
+ "—"
+ )}
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export default ItemTracingSummary;
diff --git a/src/components/ItemTracing/TraceFlowEdge.tsx b/src/components/ItemTracing/TraceFlowEdge.tsx
new file mode 100644
index 0000000..dbbfbcd
--- /dev/null
+++ b/src/components/ItemTracing/TraceFlowEdge.tsx
@@ -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 ;
+};
+
+export const traceFlowEdgeTypes = {
+ traceFlow: TraceFlowEdge,
+};
diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx
new file mode 100644
index 0000000..6a3be79
--- /dev/null
+++ b/src/components/ItemTracing/TraceFlowNodes.tsx
@@ -0,0 +1,843 @@
+"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 } 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;
+}) => (
+ {
+ e.stopPropagation();
+ onToggle?.();
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ sx={{
+ pointerEvents: "all",
+ p: 0.25,
+ ml: "auto",
+ flexShrink: 0,
+ color: "warning.dark",
+ }}
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+);
+
+export const TraceFlowEventNode = memo(function TraceFlowEventNode({
+ id,
+ data,
+}: NodeProps>) {
+ 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 ? (
+
+ ) : null}
+ {node.doOutboundIsReplenish ? (
+
+ ) : null}
+ >
+ ) : null;
+ const titleContent = isDoOut ? (
+
+ {node.docLinkKind && node.refCode ? (
+
+ ) : (
+ node.refCode || "—"
+ )}
+
+ ) : node.docLinkKind && node.refCode ? (
+
+ ) : (
+ 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 = (
+
+ {incomingCount > 0 &&
+ Array.from({ length: incomingCount }, (_, i) => (
+
+ ))}
+
+
+
+ {isQc && (
+
+ )}
+
+ {doOutboundKindChips}
+
+
+ {titleContent}
+
+ {node.subtitle?.trim() ? (
+
+ {node.subtitle}
+
+ ) : null}
+ {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? (
+
+ {t("Item")}: {node.meta?.trim() || node.traceItemCode}
+
+ ) : null}
+ {node.qty != null && (
+
+ {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)}
+
+ )}
+ {node.traceLotNo ? (
+
+ ) : null}
+ {node.warehouseCode?.trim() ? (
+
+ {t("warehouse")}: {node.warehouseCode}
+
+ ) : null}
+ {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
+ node.processingStatusLabel?.trim() ? (
+
+ {t("processingStatus")}:{" "}
+ {
+ const c = pickStatusValueColor(node.processingStatus);
+ return c === "default" ? "text.primary" : `${c}.main`;
+ })(),
+ fontWeight: 700,
+ }}
+ >
+ {node.processingStatusLabel}
+
+
+ ) : null}
+ {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
+ node.matchStatusLabel?.trim() ? (
+
+ {t("matchStatus")}:{" "}
+ {
+ const c = pickStatusValueColor(node.matchStatus);
+ return c === "default" ? "text.primary" : `${c}.main`;
+ })(),
+ fontWeight: 700,
+ }}
+ >
+ {node.matchStatusLabel}
+
+
+ ) : null}
+
+ {dateLabel}
+
+
+ {hasLifecycle ? (
+
+ {
+ 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" },
+ }}
+ >
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
+
+ {lifecycleExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
+ {outgoingCount > 0 &&
+ Array.from({ length: outgoingCount }, (_, i) => (
+
+ ))}
+
+ );
+
+ // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body).
+ const lifecyclePanel =
+ hasLifecycle && lifecycleExpanded ? (
+ 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",
+ }}
+ >
+
+ {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
+
+
+
+
+ ) : null;
+
+ const wrapped = (
+
+ {card}
+ {lifecyclePanel}
+
+ );
+
+ if (node.meta) {
+ return (
+
+ {wrapped}
+
+ );
+ }
+ return wrapped;
+});
+
+export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({
+ data,
+}: NodeProps>) {
+ return (
+
+
+ {data.phaseLabel}
+
+
+ );
+});
+
+export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({
+ data,
+}: NodeProps>) {
+ return (
+
+ {data.dateLabel}
+
+ );
+});
+
+export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({
+ data,
+}: NodeProps>) {
+ 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 (
+ 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) => (
+
+ ))}
+ alpha(theme.palette.warning.light, 0.35),
+ borderBottom: collapsed ? 0 : 1,
+ borderColor: "warning.light",
+ }}
+ >
+
+ {node.title}
+
+ {node.groupTotalQty != null ? (
+
+ {t("flowDoGroupTotalQty", {
+ qtyLabel: formatQty(node.groupTotalQty, node.groupUom),
+ })}
+
+ ) : null}
+
+
+
+ );
+});
+
+export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({
+ data,
+}: NodeProps>) {
+ 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 (
+ 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) => (
+
+ ))}
+ alpha(theme.palette.warning.light, 0.35),
+ borderBottom: collapsed ? 0 : 1,
+ borderColor: "warning.light",
+ }}
+ >
+
+ {node.title}
+
+
+
+ {outgoingCount > 0 &&
+ Array.from({ length: outgoingCount }, (_, i) => (
+
+ ))}
+
+ );
+});
+
+export const traceFlowNodeTypes = {
+ traceEvent: TraceFlowEventNode,
+ doGroup: TraceFlowDoGroupNode,
+ pickGroup: TraceFlowPickGroupNode,
+ phaseLabel: TraceFlowPhaseLabelNode,
+ dateHeader: TraceFlowDateHeaderNode,
+};
diff --git a/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
new file mode 100644
index 0000000..0377490
--- /dev/null
+++ b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
@@ -0,0 +1,277 @@
+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,
+ 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));
+};
diff --git a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts
new file mode 100644
index 0000000..537e667
--- /dev/null
+++ b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts
@@ -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;
+ seenInboundLots: Set;
+ seenReceiptLots: Set;
+ seenQcKeys: Set;
+ seenPutawayKeys: Set;
+ pickOrderTargetDateMap: Map;
+ seq: number;
+};
+
+const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`;
+
+const mergeLotPickMaps = (
+ target: Map,
+ source: Map,
+): 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 => {
+ const map = new Map();
+ 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 => buildLotPickOrderMap(materialInputs);
+
+const pickOrdersForLot = (
+ map: Map,
+ 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,
+ 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,
+ 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,
+ lotPicks: Map,
+ 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,
+): 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);
+ });
+};
diff --git a/src/components/ItemTracing/buildLocationBlockGraphNodes.ts b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts
new file mode 100644
index 0000000..f936182
--- /dev/null
+++ b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts
@@ -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));
+
diff --git a/src/components/ItemTracing/buildProductionGraphNodes.ts b/src/components/ItemTracing/buildProductionGraphNodes.ts
new file mode 100644
index 0000000..86f7036
--- /dev/null
+++ b/src/components/ItemTracing/buildProductionGraphNodes.ts
@@ -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;
+};
diff --git a/src/components/ItemTracing/buildReactFlowGraph.ts b/src/components/ItemTracing/buildReactFlowGraph.ts
new file mode 100644
index 0000000..b148797
--- /dev/null
+++ b/src/components/ItemTracing/buildReactFlowGraph.ts
@@ -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,
+ edgePairs?: TraceFlowEdgePair[],
+): { nodes: Node[]; edges: Edge[]; graphHeight: number } => {
+ const nodes: Node[] = [];
+ const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n));
+
+ const cells = new Map();
+ 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>();
+ 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();
+ 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
+ >();
+ 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();
+ 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();
+ 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;
diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts
new file mode 100644
index 0000000..9ee7d78
--- /dev/null
+++ b/src/components/ItemTracing/buildTraceGraphNodes.ts
@@ -0,0 +1,1210 @@
+import dayjs from "dayjs";
+import { ItemLotTraceJoContext, ItemLotTraceOrigin, ItemLotTraceQcResult, ItemLotTraceResponse, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing";
+import { TraceLabelTranslator, groupQcResultsBySession, resolveDoOutboundChipFlags } from "./traceLabelUtils";
+import { formatQty, formatSignedQty } from "./traceQtyUtils";
+import { docLinkFromRefType, detailsOf, field, fieldIf, parseExpirySortKey, parseSortKey, pickStatusDetailFields, pickStatusNodeLabels } from "./traceNodeFactory";
+import {
+ isPendingPutawayOriginStatus,
+ isTransferInboundPutaway,
+ matchTransferForInboundPutaway,
+ normRefType,
+ resolvePutawayPresentation,
+ shouldEmitDepletedForScope,
+ shouldEmitTransferForScope,
+} from "./tracePutawayUtils";
+import { resolveDoOutboundDocLink, resolveJoPickDocLink, buildPickOrderTargetDateMap, resolvePickOrderTargetDate } from "./traceDocLinkUtils";
+import {
+ formatStockTakeRoundLabel,
+ resolveStockTakeAcceptedQty,
+ resolveStockTakeBookQty,
+ stockTakeEventSubtitle,
+} from "./traceStockTakeUtils";
+
+export type TraceGraphNodeKind =
+ | "MATERIAL_IN"
+ | "JO_CREATED"
+ | "MATERIAL_QC"
+ | "MATERIAL_PICK"
+ | "PRODUCTION_STEP"
+ | "BYPRODUCT"
+ | "SCRAP"
+ | "DEFECT"
+ | "OPEN"
+ | "FAIL"
+ | "DO_OUT"
+ | "REPLENISHMENT_CREATED"
+ | "JO_OUT"
+ | "PO_OUT"
+ | "DO_GROUP"
+ | "PICK_GROUP"
+ | "RETURN"
+ | "REPACK"
+ | "PURCHASE"
+ | "RECEIPT"
+ | "PUTAWAY"
+ | "IN"
+ | "OUT"
+ | "QC"
+ | "STOCK_TAKE"
+ | "ADJUSTMENT"
+ | "TRANSFER"
+ | "EXPIRED"
+ | "DEPLETED";
+
+export type TraceGraphDocLinkKind = "jo" | "po" | "pick" | "workbench" | "jodetail";
+
+export interface TraceGraphDetailField {
+ label: string;
+ value: string;
+ linkKind?: TraceGraphDocLinkKind;
+ linkCode?: string;
+ linkId?: number | null;
+ consoCode?: string;
+ linkTicketNo?: string;
+ linkTargetDate?: string;
+ variant?: "default" | "qcCriteriaList";
+ qcCriteriaItems?: QcCriteriaItem[];
+ /** MUI palette key for status value text (success / error / warning / info). */
+ valueColor?: "success" | "error" | "warning" | "info" | "default";
+}
+
+export interface QcCriteriaItem {
+ name: string;
+ description?: string;
+ passed: boolean;
+ failQty?: number;
+}
+
+export interface TraceGraphNodeLabels {
+ nodeReceipt: string;
+ nodePurchase: string;
+ nodePutaway: string;
+ nodePutawayTransfer: string;
+ putawayTransferDetail: string;
+ nodeJoCreated: string;
+ nodeQcPass: string;
+ nodeQcFail: string;
+ nodeStockTake: string;
+ nodeAdjustment: string;
+ nodeTransfer: string;
+ nodeDoOut: string;
+ nodeJoOut: string;
+ nodePoOut: string;
+ directionIn: string;
+ directionOut: string;
+ formatQcSubtitle: (failQty: number, acceptedQty: number) => string;
+}
+
+export interface TraceGraphNode {
+ id: string;
+ kind: TraceGraphNodeKind;
+ timestamp: string | null;
+ sortKey: number;
+ title: string;
+ subtitle: string;
+ qty?: number;
+ uom?: string;
+ meta?: string;
+ refType?: string;
+ refCode?: string;
+ refId?: number | null;
+ docLinkKind?: TraceGraphDocLinkKind;
+ /** Workbench deep-link ticket (TI-*). */
+ docLinkTicketNo?: string;
+ docLinkTargetDate?: string;
+ traceLotNo?: string;
+ traceItemCode?: string;
+ consoCode?: string;
+ categoryLabel?: string;
+ /** Translated QC type for chip label (IQC / EPQC / …). */
+ qcTypeLabel?: string;
+ /** 待上架 / 已上架 for PUTAWAY nodes (chip). */
+ putawayStatusLabel?: string;
+ /** 加單 / 補貨 flags for DO_OUT title chips (detail uses outboundKindLabel). */
+ doOutboundIsExtra?: boolean;
+ doOutboundIsReplenish?: boolean;
+ outboundKindLabel?: string;
+ /** Longer status explanation in detail panel (e.g. transfer inbound). */
+ putawayStatusDetail?: string;
+ /** Warehouse where stock was shelved (PUTAWAY) or transfer endpoints (TRANSFER). */
+ warehouseCode?: string;
+ transferFromWarehouse?: string;
+ transferToWarehouse?: string;
+ /** BOM process step this node belongs to (material pick / production step mapping). */
+ bomProcessId?: number | null;
+ bomProcessSeqNo?: number | null;
+ assignedStepName?: string;
+ /** Item codes consumed by this production step (from BOM process materials). */
+ stepMaterialItemCodes?: string[];
+ /** Semi-finished lot whose nested production this pick feeds (nested JO only). */
+ feedsProductionScopeLotNo?: string;
+ /** Job order that owns this material pick / pick group. */
+ jobOrderCode?: string;
+ details: TraceGraphDetailField[];
+ /** When set, this node is rendered inside a DO group box. */
+ doGroupId?: string;
+ /** DO_GROUP container size (layout only). */
+ groupBoxWidth?: number;
+ groupBoxHeight?: number;
+ groupMemberCount?: number;
+ /** Summed outbound qty for DO_GROUP header. */
+ groupTotalQty?: number;
+ groupUom?: string;
+ /** Links REPLENISHMENT_CREATED → matching DO_OUT (stockOutLineId). */
+ replenishmentStockOutLineId?: number;
+ /** IN / OUT for ADJUSTMENT nodes (signed variance display). */
+ adjustmentDirection?: string;
+ /** Stocktake lifecycle record detail (for expandable STOCK_TAKE nodes). */
+ stockTakeRecordDetail?: ItemLotTraceStockTakeRecordDetail;
+ /** Owning inventory_lot for multi-location graph scopes. */
+ inventoryLotId?: number;
+ /** Translated pick-line statuses for JO pick cards. */
+ processingStatusLabel?: string;
+ matchStatusLabel?: string;
+ /** Raw status codes (for coloring). */
+ processingStatus?: string;
+ matchStatus?: string;
+}
+
+export interface TraceGraphDetailLabels extends TraceGraphNodeLabels {
+ tr: TraceLabelTranslator;
+ detailQcCriteria: string;
+ detailQcType: string;
+ detailQcUnknownItem: string;
+ nodeExpired: string;
+ nodeDepleted: string;
+ detailQty: string;
+ detailTime: string;
+ detailHandler: string;
+ detailStockTaker: string;
+ detailApprover: string;
+ detailWarehouse: string;
+ detailRemarks: string;
+ detailFailCategory: string;
+ detailProcessDescription: string;
+ detailType: string;
+ detailRef: string;
+ detailStockTakeCode: string;
+ detailAdjustmentRef: string;
+ detailReplenishmentCode: string;
+ detailReason: string;
+ detailReturnRef: string;
+ detailSourceDoc: string;
+ detailPurchaseOrderNo: string;
+ detailInboundRef: string;
+ detailInboundSiNo: string;
+ detailPutawayBin: string;
+ detailDirection: string;
+ detailStatus: string;
+ detailSupplier: string;
+ detailMaterial: string;
+ detailItemCode: string;
+ detailItemName: string;
+ detailLot: string;
+ detailAcceptedQty: string;
+ detailFailQty: string;
+ detailFrom: string;
+ detailTo: string;
+ detailVariance: string;
+ detailBefore: string;
+ detailAfter: string;
+ detailStockTakeFirstCount: string;
+ detailStockTakeSecondCount: string;
+ detailStockTakeApproverCount: string;
+ detailScrapQty: string;
+ detailDefectQty: string;
+ detailEquipment: string;
+ detailProcessStep: string;
+ detailStepMaterials: string;
+ detailAssignedStep: string;
+ detailPickTargetDate: string;
+ detailProductLotNo: string;
+ detailOrderQty: string;
+ detailPutAwayQty: string;
+ detailPurchaseUnit: string;
+ detailSupplyTo: string;
+ detailStockTakeRound: string;
+ detailStockTakeSection: string;
+ detailLocation: string;
+ deliveryOrder: string;
+ deliveryNoteCode: string;
+ ticketNo: string;
+ pickOrder: string;
+ jobOrder: string;
+ expiryDate: string;
+ totalAvailable: string;
+ itemCode: string;
+ categoryPurchase: string;
+ categoryProduction: string;
+ categoryInbound: string;
+ categoryReceipt: string;
+ categoryPutaway: string;
+ categoryTransfer: string;
+ categoryStockTake: string;
+ categoryOpen: string;
+ categoryAdjustment: string;
+ categoryPick: string;
+ categoryQc: string;
+ categoryOutbound: string;
+ categoryTerminal: string;
+ processingStatus: string;
+ matchStatus: string;
+}
+
+const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => {
+ switch (kind) {
+ case "MATERIAL_IN":
+ return labels.categoryPurchase;
+ case "JO_CREATED":
+ return labels.categoryProduction;
+ case "MATERIAL_PICK":
+ return labels.categoryPick;
+ case "PRODUCTION_STEP":
+ case "BYPRODUCT":
+ case "SCRAP":
+ case "DEFECT":
+ return labels.categoryProduction;
+ case "OPEN":
+ return labels.categoryOpen;
+ case "FAIL":
+ return labels.categoryQc;
+ case "DO_OUT":
+ case "JO_OUT":
+ case "PO_OUT":
+ case "RETURN":
+ return labels.categoryOutbound;
+ case "REPACK":
+ return labels.categoryTransfer;
+ case "PURCHASE":
+ return labels.categoryPurchase;
+ case "RECEIPT":
+ return labels.categoryReceipt;
+ case "PUTAWAY":
+ return labels.categoryPutaway;
+ case "MATERIAL_QC":
+ case "QC":
+ return labels.categoryQc;
+ case "IN":
+ return labels.categoryInbound;
+ case "TRANSFER":
+ return labels.categoryTransfer;
+ case "STOCK_TAKE":
+ return labels.categoryStockTake;
+ case "ADJUSTMENT":
+ return labels.categoryAdjustment;
+ case "OUT":
+ return labels.categoryOutbound;
+ case "EXPIRED":
+ case "DEPLETED":
+ return labels.categoryTerminal;
+ default:
+ return labels.detailType;
+ }
+};
+
+/** Movement refTypes covered by dedicated trace arrays (avoid duplicate graph nodes). */
+const MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = new Set([
+ "TRANSFER",
+ "ADJ",
+ "OPEN",
+ "TKE",
+]);
+
+/** Stock-take ledger movements are represented by stockTakeEvents nodes. */
+const isStockTakeLedgerMovement = (movementType: string) => {
+ const mt = movementType.trim().toUpperCase();
+ return mt === "TKE" || mt === "STOCKTAKE";
+};
+
+const norm = (v: string | null | undefined) => (v ?? "").trim().toUpperCase();
+
+const doMovementDedupKey = (
+ pickOrderCode: string,
+ timestamp: string | null | undefined,
+ qty: number,
+) => `${pickOrderCode.trim()}|${timestamp ?? ""}|${qty}`;
+
+const buildCoveredDoMovementKeys = (data: ItemLotTraceResponse): Set =>
+ new Set(
+ (data.doDeliveries ?? []).map((d) =>
+ doMovementDedupKey(d.pickOrderCode, d.timestamp, d.qty),
+ ),
+ );
+
+export interface TraceGraphBuildScope {
+ /** e.g. `loc-42-` for alternate inventory_lot nodes (aligned with backend traceGraph keys). */
+ idPrefix?: string;
+ locationBlockKeys?: boolean;
+ inventoryLotId?: number;
+ defaultWarehouseCode?: string;
+ /** When true, TRANSFER cards are omitted; destination shows 轉倉入庫 putaway only. */
+ mergedMultiLocation?: boolean;
+}
+
+export const scopePrefix = (scope?: TraceGraphBuildScope) => scope?.idPrefix ?? "";
+
+export const withNodeScope = (node: TraceGraphNode, scope?: TraceGraphBuildScope): TraceGraphNode => {
+ if (!scope?.inventoryLotId && !scope?.defaultWarehouseCode) return node;
+ return {
+ ...node,
+ inventoryLotId: scope.inventoryLotId ?? node.inventoryLotId,
+ warehouseCode: node.warehouseCode?.trim() || scope.defaultWarehouseCode || node.warehouseCode,
+ };
+};
+
+const buildQcNodes = (
+ qcResults: ItemLotTraceQcResult[],
+ labels: TraceGraphDetailLabels,
+ stockUom: string,
+ seqStart: number,
+ scope?: TraceGraphBuildScope,
+): { nodes: TraceGraphNode[]; nextSeq: number } => {
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const nodes: TraceGraphNode[] = [];
+ let seq = seqStart;
+ groupQcResultsBySession(qcResults).forEach((group, i) => {
+ const first = group[0];
+ if (!first) return;
+ const allPassed = group.every((q) => q.qcPassed);
+ const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
+ const metaParts = [
+ first.handledBy,
+ ...group.map((q) => q.remarks).filter(Boolean),
+ ].filter(Boolean);
+ const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
+ nodes.push({
+ id: lb
+ ? `${idPfx}qc-${i}-${first.stockInLineId}`
+ : `${idPfx}qc-${i}-${first.stockInLineId}-${first.created}`,
+ kind: "QC",
+ timestamp: first.created,
+ sortKey: parseSortKey(first.created, seq++),
+ title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
+ subtitle: "",
+ qty: totalFail,
+ uom: stockUom,
+ meta: metaParts.length ? Array.from(new Set(metaParts)).join(" · ") : undefined,
+ categoryLabel: labels.categoryQc,
+ qcTypeLabel: labels.tr.qcType(qcTypeRaw),
+ details: detailsOf(
+ 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, stockUom)),
+ field(labels.detailFailQty, formatQty(totalFail, stockUom)),
+ field(labels.detailHandler, first.handledBy),
+ field(labels.detailTime, first.created),
+ fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")),
+ ),
+ });
+ });
+ return { nodes, nextSeq: seq };
+};
+
+const resolveJoCreatedTimestamp = (
+ data: ItemLotTraceResponse,
+ refCode: string | null | undefined,
+ fallback: string | null | undefined,
+): string | null => {
+ const code = refCode?.trim();
+ if (!code) return fallback ?? null;
+
+ const candidates: Array = [
+ data.joPrelude?.jobOrder,
+ ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder),
+ ];
+
+ for (const jo of candidates) {
+ if (jo?.jobOrderCode?.trim() === code && jo.createdAt?.trim()) {
+ return jo.createdAt;
+ }
+ }
+ return fallback ?? null;
+};
+
+const poInboundKey = (
+ refCode: string | null | undefined,
+ refId: number | null | undefined,
+ qty: number,
+): string => `${(refCode ?? "").trim()}|${refId ?? 0}|${qty}`;
+
+const buildInboundOriginNodes = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphDetailLabels,
+ stockUom: string,
+ seqStart: number,
+ scope: TraceGraphBuildScope | undefined,
+ existingPurchaseCodes: Set,
+ coveredPoInbound: Set,
+ coveredJoInbound: Set,
+): { nodes: TraceGraphNode[]; nextSeq: number } => {
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const defaultWh = scope?.defaultWarehouseCode;
+ const nodes: TraceGraphNode[] = [];
+ let seq = seqStart;
+
+ (data.origins ?? []).forEach((origin, i) => {
+ const originType = norm(origin.type);
+ const linkKind = docLinkFromRefType(origin.type);
+ const supplierLabel = [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ");
+
+ if (originType === "PO") {
+ const refCode = origin.refCode?.trim();
+ if (!refCode) return;
+ coveredPoInbound.add(poInboundKey(origin.refCode, origin.refId, origin.acceptedQty));
+
+ if (!existingPurchaseCodes.has(refCode)) {
+ existingPurchaseCodes.add(refCode);
+ nodes.push({
+ id: lb
+ ? `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`
+ : `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`,
+ kind: "PURCHASE",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: labels.nodePurchase,
+ subtitle: [supplierLabel, data.lot.itemCode].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: "PO",
+ refCode,
+ refId: origin.refId,
+ docLinkKind: "po",
+ categoryLabel: labels.categoryPurchase,
+ details: [
+ field(labels.detailSupplier, supplierLabel || "—"),
+ field(labels.detailOrderQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailPurchaseOrderNo, refCode, {
+ linkKind: "po",
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailTime, origin.receiptDate),
+ ],
+ });
+ }
+
+ nodes.push({
+ id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
+ kind: "RECEIPT",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: labels.nodeReceipt,
+ subtitle: [refCode, supplierLabel].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ warehouseCode: defaultWh,
+ categoryLabel: categoryForKind("RECEIPT", labels),
+ details: detailsOf(
+ field(labels.detailPurchaseOrderNo, refCode, {
+ linkKind,
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, origin.receiptDate),
+ ),
+ });
+ return;
+ }
+
+ if (originType === "JO") {
+ const refCode = origin.refCode?.trim();
+ if (!refCode) return;
+ const joKey = `${refCode}|${origin.refId ?? 0}`;
+ coveredJoInbound.add(joKey);
+ const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate);
+ nodes.push({
+ id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
+ kind: "JO_CREATED",
+ timestamp: eventTimestamp,
+ sortKey: parseSortKey(eventTimestamp, seq++),
+ title: labels.nodeJoCreated,
+ subtitle: refCode,
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ warehouseCode: defaultWh,
+ categoryLabel: categoryForKind("JO_CREATED", labels),
+ details: detailsOf(
+ field(labels.detailType, labels.nodeJoCreated),
+ field(labels.jobOrder, refCode, {
+ linkKind,
+ linkCode: refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, eventTimestamp),
+ ),
+ });
+ }
+ });
+
+ return { nodes, nextSeq: seq };
+};
+
+export const buildTraceGraphNodes = (
+ data: ItemLotTraceResponse,
+ labels: TraceGraphDetailLabels,
+ scope?: TraceGraphBuildScope,
+): TraceGraphNode[] => {
+ const nodes: TraceGraphNode[] = [];
+ let seq = 0;
+ const stockUom = data.lot.uom;
+ const idPfx = scopePrefix(scope);
+ const lb = scope?.locationBlockKeys ?? false;
+ const primaryWh =
+ data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined;
+ const defaultScope: TraceGraphBuildScope | undefined = scope
+ ? scope
+ : primaryWh
+ ? { defaultWarehouseCode: primaryWh, inventoryLotId: data.lot.inventoryLotId }
+ : { inventoryLotId: data.lot.inventoryLotId };
+ const mergedMultiLocation = defaultScope?.mergedMultiLocation ?? false;
+
+ const existingPurchaseCodes = new Set();
+ const coveredPoInbound = new Set();
+ const coveredJoInbound = new Set();
+
+ (data.purchaseEvents ?? []).forEach((purchase, i) => {
+ const purchaseUnit = purchase.purchaseUnit;
+ const supplierLabel = [purchase.supplierCode, purchase.supplierName].filter(Boolean).join(" ");
+ const poCode = purchase.purchaseOrderCode?.trim();
+ if (poCode) existingPurchaseCodes.add(poCode);
+ const subtitle =
+ [supplierLabel, purchase.itemCode, purchase.itemName].filter(Boolean).join(" · ") || "—";
+ const meta = [supplierLabel].filter(Boolean).join(" · ");
+ nodes.push({
+ id: `${idPfx}purchase-${i}-${purchase.purchaseOrderLineId}`,
+ kind: "PURCHASE",
+ timestamp: purchase.orderDate,
+ sortKey: parseSortKey(purchase.orderDate, seq++),
+ title: labels.nodePurchase,
+ subtitle,
+ qty: purchase.orderQty,
+ uom: purchaseUnit,
+ meta: meta || undefined,
+ refType: "PO",
+ refCode: purchase.purchaseOrderCode,
+ refId: purchase.purchaseOrderId,
+ docLinkKind: "po",
+ categoryLabel: labels.categoryPurchase,
+ details: [
+ field(labels.detailItemCode, purchase.itemCode),
+ field(labels.detailItemName, purchase.itemName),
+ field(labels.detailSupplier, supplierLabel),
+ field(labels.detailOrderQty, formatQty(purchase.orderQty, purchaseUnit)),
+ field(labels.detailPurchaseUnit, purchase.purchaseUnit),
+ field(labels.detailPurchaseOrderNo, purchase.purchaseOrderCode, {
+ linkKind: "po",
+ linkCode: purchase.purchaseOrderCode,
+ linkId: purchase.purchaseOrderId,
+ }),
+ field(labels.detailTime, purchase.orderDate),
+ ],
+ });
+ });
+
+ const originBuilt = buildInboundOriginNodes(
+ data,
+ labels,
+ stockUom,
+ seq,
+ defaultScope,
+ existingPurchaseCodes,
+ coveredPoInbound,
+ coveredJoInbound,
+ );
+ nodes.push(...originBuilt.nodes);
+ seq = originBuilt.nextSeq;
+
+ const coveredDoMovementKeys = buildCoveredDoMovementKeys(data);
+ const pickOrderTargetDateMap = buildPickOrderTargetDateMap(data);
+ const doMetaByPickCode = new Map<
+ string,
+ {
+ isExtra: boolean;
+ isReplenish: boolean;
+ consoCode?: string;
+ }
+ >();
+ (data.doDeliveries ?? []).forEach((d) => {
+ const code = d.pickOrderCode?.trim();
+ if (!code) return;
+ const chipFlags = resolveDoOutboundChipFlags({
+ isExtra: d.isExtra,
+ isReplenish: d.isReplenish,
+ ticketNo: d.ticketNo,
+ consoCode: d.consoCode,
+ releaseType: d.releaseType,
+ deliveryOrderPickOrderId: d.deliveryOrderPickOrderId,
+ relationshipId: d.relationshipId,
+ });
+ doMetaByPickCode.set(code, {
+ isExtra: chipFlags.isExtra,
+ isReplenish: chipFlags.isReplenish,
+ consoCode: d.consoCode?.trim() || code,
+ });
+ });
+
+ data.movements.forEach((m, i) => {
+ const refType = norm(m.refType);
+ if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.has(refType)) return;
+ if (isStockTakeLedgerMovement(m.movementType)) return;
+ if (
+ refType === "DO" &&
+ coveredDoMovementKeys.has(doMovementDedupKey(m.refCode, m.timestamp, m.qty))
+ ) {
+ return;
+ }
+ if (refType === "RETURN" && (data.returnEvents?.length ?? 0) > 0) return;
+
+ if (m.direction === "IN" && refType === "PO") {
+ if (coveredPoInbound.has(poInboundKey(m.refCode, m.refId, m.qty))) return;
+ coveredPoInbound.add(poInboundKey(m.refCode, m.refId, m.qty));
+ }
+ if (m.direction === "IN" && refType === "JO") {
+ const joKey = `${(m.refCode ?? "").trim()}|${m.refId ?? 0}`;
+ if (coveredJoInbound.has(joKey)) return;
+ coveredJoInbound.add(joKey);
+ }
+
+ const isDoOut = refType === "DO" && m.direction !== "IN";
+ const isJoOut = refType === "JO_PICK" && m.direction !== "IN";
+ const isPoOut = refType === "PO_PICK" && m.direction !== "IN";
+ const kind: TraceGraphNodeKind =
+ m.direction === "IN"
+ ? refType === "PO"
+ ? "RECEIPT"
+ : refType === "JO"
+ ? "JO_CREATED"
+ : "IN"
+ : isDoOut
+ ? "DO_OUT"
+ : isJoOut
+ ? "JO_OUT"
+ : isPoOut
+ ? "PO_OUT"
+ : "OUT";
+ const eventTimestamp =
+ kind === "JO_CREATED"
+ ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp)
+ : m.timestamp;
+ const title =
+ kind === "RECEIPT"
+ ? labels.nodeReceipt
+ : kind === "JO_CREATED"
+ ? labels.nodeJoCreated
+ : kind === "DO_OUT"
+ ? `${labels.nodeDoOut} · ${m.refCode || "—"}`
+ : kind === "JO_OUT"
+ ? `${labels.nodeJoOut} · ${m.refCode || "—"}`
+ : kind === "PO_OUT"
+ ? `${labels.nodePoOut} · ${m.refCode || "—"}`
+ : `${labels.tr.refType(m.refType)} · ${labels.tr.movementType(m.movementType)}`;
+ const subtitle =
+ kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT"
+ ? ""
+ : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—";
+ const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · ");
+ const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType);
+ const receiptDetails =
+ kind === "RECEIPT"
+ ? detailsOf(
+ field(labels.detailPurchaseOrderNo, m.refCode, {
+ linkKind,
+ linkCode: m.refCode,
+ linkId: m.refId,
+ }),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "DO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodeDoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "pick",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "JO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodeJoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "jodetail",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ linkTargetDate: resolvePickOrderTargetDate(
+ pickOrderTargetDateMap,
+ m.refCode,
+ m.timestamp,
+ ),
+ }),
+ field(
+ labels.detailPickTargetDate,
+ resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp),
+ ),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, {
+ always: true,
+ }),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : kind === "PO_OUT"
+ ? detailsOf(
+ field(labels.detailType, labels.nodePoOut),
+ field(labels.pickOrder, m.refCode, {
+ linkKind: "pick",
+ linkCode: m.refCode,
+ linkId: m.refId,
+ consoCode: m.refCode,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, m.timestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ )
+ : detailsOf(
+ field(
+ labels.detailType,
+ kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType),
+ ),
+ field(labels.detailDirection, labels.tr.direction(m.direction)),
+ field(labels.detailSourceDoc, m.refCode, {
+ linkKind,
+ linkCode: m.refCode,
+ linkId: m.refId,
+ }),
+ field(labels.detailWarehouse, m.warehouseCode),
+ field(labels.detailQty, formatQty(m.qty, stockUom)),
+ field(labels.detailHandler, m.handledBy),
+ field(labels.detailTime, eventTimestamp),
+ fieldIf(labels.detailRemarks, m.remarks),
+ );
+ const pickMeta = isDoOut ? doMetaByPickCode.get(m.refCode?.trim() ?? "") : undefined;
+ const delivery = isDoOut
+ ? (data.doDeliveries ?? []).find((d) => d.pickOrderCode?.trim() === m.refCode?.trim())
+ : undefined;
+ const docLink = isDoOut
+ ? resolveDoOutboundDocLink({
+ pickOrderCode: delivery?.pickOrderCode || m.refCode,
+ pickOrderId: delivery?.pickOrderId ?? m.refId,
+ deliveryOrderCode: delivery?.deliveryOrderCode,
+ ticketNo: delivery?.ticketNo,
+ outboundTicketNo: m.outboundTicketNo,
+ consoCode: delivery?.consoCode || m.pickConsoCode,
+ timestamp: m.timestamp,
+ deliveryOrderPickOrderId:
+ delivery?.deliveryOrderPickOrderId ?? m.deliveryOrderPickOrderId,
+ })
+ : null;
+ const joPickTargetDate = isJoOut
+ ? resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp)
+ : undefined;
+ const joDocLink = isJoOut
+ ? resolveJoPickDocLink({
+ pickOrderCode: m.refCode,
+ pickOrderId: m.refId,
+ timestamp: m.timestamp,
+ targetDate: joPickTargetDate,
+ })
+ : null;
+ const movementChip = isDoOut
+ ? resolveDoOutboundChipFlags({
+ isExtra: pickMeta ? undefined : m.doOutboundIsExtra,
+ isReplenish: pickMeta ? undefined : m.doOutboundIsReplenish,
+ ticketNo: pickMeta ? undefined : m.outboundTicketNo,
+ consoCode: pickMeta ? undefined : (m.pickConsoCode || m.refCode),
+ deliveryOrderPickOrderId: pickMeta
+ ? undefined
+ : m.deliveryOrderPickOrderId,
+ relationshipId: pickMeta ? undefined : m.relationshipId,
+ })
+ : null;
+ const chipFlags = pickMeta
+ ? resolveDoOutboundChipFlags({
+ isExtra: pickMeta.isExtra,
+ isReplenish: pickMeta.isReplenish,
+ consoCode: pickMeta.consoCode,
+ })
+ : movementChip;
+ nodes.push({
+ id: lb
+ ? `${idPfx}mov-${i}-${m.refCode}`
+ : `${idPfx}mov-${i}-${m.refCode}-${eventTimestamp}`,
+ kind,
+ timestamp: eventTimestamp,
+ sortKey: parseSortKey(eventTimestamp, seq++),
+ title,
+ subtitle,
+ qty: m.qty,
+ uom: stockUom,
+ meta: meta || undefined,
+ refType: m.refType,
+ refCode: isDoOut ? (docLink?.displayCode ?? m.refCode) : m.refCode,
+ refId: m.refId,
+ docLinkKind: isDoOut ? docLink?.kind : isJoOut ? joDocLink?.kind : linkKind,
+ docLinkTicketNo: docLink?.ticketNo,
+ docLinkTargetDate: isDoOut ? docLink?.targetDate : joDocLink?.targetDate,
+ consoCode: isDoOut
+ ? (docLink?.consoCode ?? pickMeta?.consoCode ?? (m.pickConsoCode?.trim() || m.refCode))
+ : isJoOut || isPoOut
+ ? (m.pickConsoCode?.trim() || m.refCode?.trim() || undefined)
+ : undefined,
+ doOutboundIsExtra: chipFlags?.isExtra,
+ doOutboundIsReplenish: chipFlags?.isReplenish,
+ warehouseCode: m.warehouseCode?.trim() || undefined,
+ categoryLabel: categoryForKind(kind, labels),
+ ...(isJoOut
+ ? pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true })
+ : {}),
+ details: receiptDetails,
+ });
+ });
+
+ const qcBuilt = buildQcNodes(data.qcResults, labels, stockUom, seq, scope);
+ nodes.push(...qcBuilt.nodes);
+ seq = qcBuilt.nextSeq;
+
+ const coveredPutawaySilIds = new Set();
+ const putawayLabels = {
+ nodePutaway: labels.nodePutaway,
+ nodePutawayTransfer: labels.nodePutawayTransfer,
+ putawayTransferDetail: labels.putawayTransferDetail,
+ };
+ (data.putawayEvents ?? []).forEach((putaway, i) => {
+ const matchedTransfer = isTransferInboundPutaway(putaway.refType)
+ ? matchTransferForInboundPutaway(putaway, data.transfers ?? [])
+ : undefined;
+ if (isTransferInboundPutaway(putaway.refType)) {
+ // Represented by TRANSFER cards (one per TR-*); skip destination putaway cards.
+ if (putaway.stockInLineId != null) {
+ coveredPutawaySilIds.add(putaway.stockInLineId);
+ }
+ return;
+ }
+ const linkKind = docLinkFromRefType(putaway.refType);
+ const pres = resolvePutawayPresentation(labels.tr, putawayLabels, putaway.status, putaway.refType);
+ const meta = [pres.chipLabel, putaway.handledBy, putaway.warehouseCode].filter(Boolean).join(" · ");
+ nodes.push({
+ id: lb
+ ? `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}`
+ : `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}-${putaway.timestamp}`,
+ kind: "PUTAWAY",
+ timestamp: putaway.timestamp,
+ sortKey: parseSortKey(putaway.timestamp, seq++),
+ title: pres.title,
+ subtitle: [pres.chipLabel, putaway.refCode, putaway.warehouseCode].filter(Boolean).join(" · ") || "—",
+ qty: putaway.qty,
+ uom: stockUom,
+ meta: meta || undefined,
+ refType: putaway.refType,
+ refCode: putaway.refCode,
+ refId: putaway.refId,
+ warehouseCode: putaway.warehouseCode,
+ transferFromWarehouse: matchedTransfer?.fromWarehouse ?? undefined,
+ transferToWarehouse: matchedTransfer?.toWarehouse ?? undefined,
+ docLinkKind: linkKind,
+ putawayStatusLabel: pres.chipLabel,
+ putawayStatusDetail: pres.statusDetail,
+ categoryLabel: labels.categoryPutaway,
+ details: [
+ field(labels.detailStatus, pres.statusDetail),
+ field(
+ isTransferInboundPutaway(putaway.refType)
+ ? labels.detailInboundSiNo
+ : normRefType(putaway.refType) === "PO"
+ ? labels.detailPurchaseOrderNo
+ : normRefType(putaway.refType) === "JO"
+ ? labels.jobOrder
+ : labels.detailSourceDoc,
+ putaway.refCode,
+ {
+ linkKind,
+ linkCode: putaway.refCode,
+ linkId: putaway.refId,
+ }),
+ ...(matchedTransfer
+ ? [
+ field(labels.detailFrom, matchedTransfer.fromWarehouse),
+ field(labels.detailTo, matchedTransfer.toWarehouse),
+ ]
+ : []),
+ field(labels.detailPutawayBin, putaway.warehouseCode),
+ field(labels.detailQty, formatQty(putaway.qty, stockUom)),
+ field(labels.detailHandler, putaway.handledBy),
+ field(labels.detailTime, putaway.timestamp),
+ ],
+ });
+ if (putaway.stockInLineId != null) {
+ coveredPutawaySilIds.add(putaway.stockInLineId);
+ }
+ });
+
+ (data.origins ?? []).forEach((origin, i) => {
+ if (coveredPutawaySilIds.has(origin.stockInLineId)) return;
+ if (!isPendingPutawayOriginStatus(origin.status)) return;
+ const originType = norm(origin.type);
+ if (!["PO", "JO", "TKE", "STOCK_IN"].includes(originType)) return;
+ if (isTransferInboundPutaway(origin.type)) return;
+ const pres = resolvePutawayPresentation(labels.tr, putawayLabels, origin.status, origin.type);
+ const linkKind = docLinkFromRefType(origin.type);
+ nodes.push({
+ id: `putaway-pending-${origin.stockInLineId}-${i}`,
+ kind: "PUTAWAY",
+ timestamp: origin.receiptDate,
+ sortKey: parseSortKey(origin.receiptDate, seq++),
+ title: pres.title,
+ subtitle: [pres.chipLabel, origin.refCode].filter(Boolean).join(" · ") || "—",
+ qty: origin.acceptedQty,
+ uom: stockUom,
+ refType: origin.type,
+ refCode: origin.refCode,
+ refId: origin.refId,
+ docLinkKind: linkKind,
+ putawayStatusLabel: pres.chipLabel,
+ putawayStatusDetail: pres.statusDetail,
+ categoryLabel: labels.categoryPutaway,
+ details: [
+ field(labels.detailStatus, pres.statusDetail),
+ field(
+ originType === "PO"
+ ? labels.detailPurchaseOrderNo
+ : originType === "JO"
+ ? labels.jobOrder
+ : labels.detailSourceDoc,
+ origin.refCode,
+ {
+ linkKind,
+ linkCode: origin.refCode,
+ linkId: origin.refId,
+ }),
+ field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
+ field(labels.detailTime, origin.receiptDate),
+ ],
+ });
+ });
+
+ data.stockTakeEvents.forEach((e, i) => {
+ const roundLabel = formatStockTakeRoundLabel(e);
+ const detail = e.recordDetail ?? undefined;
+ const bookQty = resolveStockTakeBookQty(detail, e.beforeQty);
+ const acceptedQty = resolveStockTakeAcceptedQty(detail, e.afterQty);
+ const varianceQty =
+ detail?.varianceQty != null ? Number(detail.varianceQty) : e.varianceQty;
+ const meta = [e.approver, roundLabel, e.stockTakeSection, `Δ ${formatQty(varianceQty, stockUom)}`]
+ .filter(Boolean)
+ .join(" · ");
+ nodes.push({
+ id: lb
+ ? `${idPfx}st-${i}-${e.stockTakeCode}`
+ : `${idPfx}st-${i}-${e.stockTakeCode}-${e.timestamp}`,
+ kind: "STOCK_TAKE",
+ timestamp: e.timestamp,
+ sortKey: parseSortKey(e.timestamp, seq++),
+ title: labels.nodeStockTake,
+ subtitle: stockTakeEventSubtitle(e),
+ qty: acceptedQty,
+ uom: stockUom,
+ meta: meta || undefined,
+ refCode: e.stockTakeCode,
+ warehouseCode: e.warehouseCode?.trim() || undefined,
+ traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined,
+ categoryLabel: labels.categoryStockTake,
+ stockTakeRecordDetail: detail,
+ details: [
+ field(labels.detailStockTakeCode, e.stockTakeCode),
+ fieldIf(labels.detailStockTakeRound, roundLabel || undefined),
+ fieldIf(labels.detailStockTakeSection, e.stockTakeSection),
+ fieldIf(labels.detailLocation, e.warehouseCode),
+ fieldIf(labels.detailLot, e.lotNo || data.lot.lotNo),
+ fieldIf(labels.detailItemCode, e.itemCode),
+ fieldIf(labels.detailItemName, e.itemName),
+ field(labels.detailBefore, formatQty(bookQty, stockUom)),
+ fieldIf(
+ labels.detailStockTakeFirstCount,
+ detail?.pickerFirstQty != null
+ ? formatQty(detail.pickerFirstQty, stockUom)
+ : undefined,
+ ),
+ fieldIf(
+ labels.detailStockTakeSecondCount,
+ detail?.pickerSecondQty != null
+ ? formatQty(detail.pickerSecondQty, stockUom)
+ : undefined,
+ ),
+ fieldIf(
+ labels.detailStockTakeApproverCount,
+ detail?.lastSelect === 3 && detail.approverQty != null
+ ? formatQty(detail.approverQty, stockUom)
+ : undefined,
+ ),
+ field(labels.detailAfter, formatQty(acceptedQty, stockUom)),
+ field(labels.detailVariance, formatQty(varianceQty, stockUom)),
+ fieldIf(labels.detailStockTaker, detail?.stockTakerName),
+ fieldIf(labels.detailApprover, detail?.approverName),
+ // Fallback when record detail is absent: keep legacy single handler field.
+ !detail?.stockTakerName && !detail?.approverName
+ ? field(labels.detailHandler, e.approver)
+ : null,
+ field(labels.detailTime, e.timestamp),
+ ].filter((row): row is TraceGraphDetailField => Boolean(row)),
+ });
+ });
+
+ data.adjustments.forEach((a, i) => {
+ if (norm(a.adjustmentType) === "TKE" && (data.stockTakeEvents?.length ?? 0) > 0) return;
+ const meta = [a.handledBy, a.reason].filter(Boolean).join(" · ");
+ const adjustmentWh =
+ a.warehouseCode?.trim() || defaultScope?.defaultWarehouseCode;
+ nodes.push({
+ id: lb
+ ? `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}`
+ : `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}-${a.direction}`,
+ kind: "ADJUSTMENT",
+ timestamp: a.timestamp,
+ sortKey: parseSortKey(a.timestamp, seq++),
+ title: `${labels.nodeAdjustment} (${labels.tr.direction(a.direction)})`,
+ subtitle: [labels.tr.adjustmentType(a.adjustmentType), a.refCode].filter(Boolean).join(" · ") || "—",
+ qty: a.qty,
+ uom: stockUom,
+ adjustmentDirection: a.direction,
+ meta: meta || undefined,
+ refCode: a.refCode,
+ warehouseCode: adjustmentWh,
+ categoryLabel: labels.categoryAdjustment,
+ details: detailsOf(
+ field(labels.detailVariance, formatSignedQty(a.qty, a.direction, stockUom)),
+ field(labels.detailAdjustmentRef, a.refCode),
+ fieldIf(labels.detailRemarks, a.reason),
+ ...(adjustmentWh ? [field(labels.detailWarehouse, adjustmentWh)] : []),
+ field(labels.detailHandler, a.handledBy),
+ field(labels.detailTime, a.timestamp),
+ ),
+ });
+ });
+
+ const normWh = (w?: string | null) => (w ?? "").trim().toUpperCase();
+ data.transfers.forEach((tr, i) => {
+ if (!shouldEmitTransferForScope(defaultScope?.locationBlockKeys)) {
+ return;
+ }
+ const inboundSi = (data.putawayEvents ?? []).find(
+ (p) =>
+ isTransferInboundPutaway(p.refType) &&
+ normWh(p.warehouseCode) === normWh(tr.toWarehouse),
+ );
+ nodes.push({
+ id: lb
+ ? `${idPfx}tr-${i}-${tr.transferCode}`
+ : `${idPfx}tr-${i}-${tr.transferCode}-${tr.timestamp}`,
+ kind: "TRANSFER",
+ timestamp: tr.timestamp,
+ sortKey: parseSortKey(tr.timestamp, seq++),
+ title: labels.nodeTransfer,
+ subtitle: "",
+ qty: tr.qty,
+ uom: stockUom,
+ refCode: tr.transferCode,
+ transferFromWarehouse: tr.fromWarehouse,
+ transferToWarehouse: tr.toWarehouse,
+ categoryLabel: labels.categoryTransfer,
+ details: [
+ field(labels.detailFrom, tr.fromWarehouse),
+ field(labels.detailTo, tr.toWarehouse),
+ field(labels.detailQty, formatQty(tr.qty, stockUom)),
+ ...(inboundSi?.refCode
+ ? [field(labels.detailInboundSiNo, inboundSi.refCode)]
+ : []),
+ field(labels.detailTime, tr.timestamp),
+ ],
+ });
+ });
+
+ const totalAvailable = data.warehouseLines.reduce((s, w) => s + (w.availableQty ?? 0), 0);
+ const expiry = data.lot.expiryDate;
+ if (expiry && dayjs(expiry).isValid() && dayjs(expiry).isBefore(dayjs(), "day")) {
+ nodes.push({
+ id: `${idPfx}terminal-expired-${data.lot.lotNo}`,
+ kind: "EXPIRED",
+ timestamp: expiry,
+ sortKey: parseExpirySortKey(expiry, seq + 1_000_000),
+ title: labels.nodeExpired,
+ subtitle: expiry,
+ categoryLabel: labels.categoryTerminal,
+ details: [
+ field(labels.expiryDate, expiry),
+ field(labels.totalAvailable, formatQty(totalAvailable, stockUom)),
+ field(labels.detailStatus, labels.nodeExpired),
+ ],
+ });
+ }
+ if (
+ data.warehouseLines.length > 0 &&
+ shouldEmitDepletedForScope(
+ totalAvailable,
+ defaultScope?.defaultWarehouseCode,
+ data.transfers ?? [],
+ mergedMultiLocation,
+ )
+ ) {
+ const lastTs =
+ data.movements[0]?.timestamp ??
+ data.outboundUsage[0]?.timestamp ??
+ data.lot.stockInDate;
+ nodes.push({
+ id: `${idPfx}terminal-depleted-${data.lot.lotNo}`,
+ kind: "DEPLETED",
+ timestamp: lastTs,
+ sortKey: parseSortKey(lastTs, seq + 2_000_000),
+ title: labels.nodeDepleted,
+ subtitle: `${labels.totalAvailable}: ${formatQty(0, stockUom)}`,
+ categoryLabel: labels.categoryTerminal,
+ details: [
+ field(labels.totalAvailable, formatQty(0, stockUom)),
+ field(labels.detailTime, lastTs),
+ field(labels.detailStatus, labels.nodeDepleted),
+ ],
+ });
+ }
+
+ return nodes
+ .map((n) => withNodeScope(n, defaultScope))
+ .sort((a, b) => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.id.localeCompare(b.id);
+ });
+};
+
+export { categoryForKind };
diff --git a/src/components/ItemTracing/compileTraceGraph.ts b/src/components/ItemTracing/compileTraceGraph.ts
new file mode 100644
index 0000000..60ef281
--- /dev/null
+++ b/src/components/ItemTracing/compileTraceGraph.ts
@@ -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,
+ };
+};
diff --git a/src/components/ItemTracing/exportItemLotTraceXlsx.ts b/src/components/ItemTracing/exportItemLotTraceXlsx.ts
new file mode 100644
index 0000000..e065953
--- /dev/null
+++ b/src/components/ItemTracing/exportItemLotTraceXlsx.ts
@@ -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[],
+ emptyTemplate: Record,
+): Record[] => {
+ 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 => ({
+ "Field / 欄位": "",
+ "Value / 值": "",
+});
+
+const buildSummarySheet = (
+ data: ItemLotTraceResponse,
+ labels: ItemLotTraceExportLabels,
+ exportAt: string,
+): Record[] => {
+ const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
+ const totalAvailable = warehouseLines.reduce(
+ (s, w) => s + (w.availableQty ?? 0),
+ 0,
+ );
+ const rows: Record[] = [
+ { "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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ 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 => ({
+ "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[] => {
+ const rows: Record[] = [];
+ 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. 4 | v1.0.0 | 2026-07-15 */
+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));
+};
diff --git a/src/components/ItemTracing/index.ts b/src/components/ItemTracing/index.ts
new file mode 100644
index 0000000..f1bda39
--- /dev/null
+++ b/src/components/ItemTracing/index.ts
@@ -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";
diff --git a/src/components/ItemTracing/itemTracingTableFilters.tsx b/src/components/ItemTracing/itemTracingTableFilters.tsx
new file mode 100644
index 0000000..599173f
--- /dev/null
+++ b/src/components/ItemTracing/itemTracingTableFilters.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from "@mui/material";
+import { type ReactNode } from "react";
+
+export type FilterableColumnDef = {
+ 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 = {
+ rows: T[];
+ columns: FilterableColumnDef[];
+ 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({
+ rows,
+ columns,
+ getRowKey,
+ emptyLabel,
+ size = "small",
+ collapseAfter,
+ showAll = false,
+ onToggleShowAll,
+ showAllLabel,
+ collapseLabel,
+}: FilterableDataTableProps) {
+ const collapsed =
+ collapseAfter != null && rows.length > collapseAfter && !showAll;
+ const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows;
+
+ return (
+ <>
+
+
+
+ {columns.map((col) => (
+
+ {col.label}
+
+ ))}
+
+
+
+ {visibleRows.length === 0 ? (
+
+
+
+ {emptyLabel}
+
+
+
+ ) : (
+ visibleRows.map((row, index) => (
+
+ {columns.map((col) => (
+
+ {col.cell ? col.cell(row) : (col.value(row) ?? "—")}
+
+ ))}
+
+ ))
+ )}
+
+
+ {collapseAfter != null &&
+ rows.length > collapseAfter &&
+ onToggleShowAll && (
+
+ {showAll
+ ? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`)
+ : resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ItemTracing/mergeLocationScopedData.ts b/src/components/ItemTracing/mergeLocationScopedData.ts
new file mode 100644
index 0000000..1bab5a1
--- /dev/null
+++ b/src/components/ItemTracing/mergeLocationScopedData.ts
@@ -0,0 +1,316 @@
+import type {
+ ItemLotTraceLocationBlock,
+ ItemLotTraceResponse,
+} from "@/app/api/itemTracing";
+import { blockWarehouseCode } from "./buildLocationBlockGraphNodes";
+
+export type ScopedRow = T & {
+ scopeWarehouseCode: string;
+ inventoryLotId: number;
+};
+
+export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string =>
+ data.warehouseLines
+ .map((w) => w.warehouseCode)
+ .filter(Boolean)
+ .join(" / ") || "—";
+
+const sortByTimestampDesc = (
+ rows: T[],
+): T[] =>
+ [...rows].sort((a, b) =>
+ (b.timestamp ?? "").localeCompare(a.timestamp ?? ""),
+ );
+
+const sortByReceiptDateDesc = (
+ 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;
diff --git a/src/components/ItemTracing/traceDoGroupLayout.ts b/src/components/ItemTracing/traceDoGroupLayout.ts
new file mode 100644
index 0000000..3a6066b
--- /dev/null
+++ b/src/components/ItemTracing/traceDoGroupLayout.ts
@@ -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 => {
+ const sorted = [...children].sort(sortNodesInPhase);
+ const { cols } = computeDoGroupBoxLayout(sorted.length);
+ const childW = DO_GROUP_CHILD_WIDTH;
+ const result = new Map();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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());
diff --git a/src/components/ItemTracing/traceDocLinkUtils.ts b/src/components/ItemTracing/traceDocLinkUtils.ts
new file mode 100644
index 0000000..56a4882
--- /dev/null
+++ b/src/components/ItemTracing/traceDocLinkUtils.ts
@@ -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,
+): 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 => {
+ const map = new Map();
+ walkJoPreludePickOrders(prelude, map);
+ return map;
+};
+
+export const buildPickOrderTargetDateMap = (
+ data: ItemLotTraceResponse,
+): Map =>
+ data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map();
+
+export const resolvePickOrderTargetDate = (
+ map: Map,
+ 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: "—" };
+};
diff --git a/src/components/ItemTracing/traceFlowConstants.ts b/src/components/ItemTracing/traceFlowConstants.ts
new file mode 100644
index 0000000..41ee187
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowConstants.ts
@@ -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;
diff --git a/src/components/ItemTracing/traceFlowEdgeLayout.ts b/src/components/ItemTracing/traceFlowEdgeLayout.ts
new file mode 100644
index 0000000..c479123
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowEdgeLayout.ts
@@ -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;
+ outgoingCount: Map;
+} => {
+ const bySource = new Map();
+ const byTarget = new Map();
+
+ 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();
+ const outgoingCount = new Map();
+ 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,
+ lanes: TraceFlowLaneLayout,
+): RoutedTraceFlowEdge[] => {
+ const outDegree = new Map();
+ 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();
+ const groups = new Map();
+
+ 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();
+ 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();
+ 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();
+ 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}%`;
+};
diff --git a/src/components/ItemTracing/traceFlowLayout.ts b/src/components/ItemTracing/traceFlowLayout.ts
new file mode 100644
index 0000000..9469290
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowLayout.ts
@@ -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 => {
+ const result = new Map();
+ 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 => {
+ const sorted = [...members].sort(sortNodesInPhase);
+ const result = new Map();
+ 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,
+): { 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 };
+};
diff --git a/src/components/ItemTracing/traceFlowNodeUtils.ts b/src/components/ItemTracing/traceFlowNodeUtils.ts
new file mode 100644
index 0000000..30a1a1d
--- /dev/null
+++ b/src/components/ItemTracing/traceFlowNodeUtils.ts
@@ -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 = {
+ success: "#2e7d32",
+ warning: "#ed6c02",
+ info: "#0288d1",
+ secondary: "#5c6bc0",
+ primary: "#7b1fa2",
+ default: "#9e9e9e",
+ error: "#d32f2f",
+ };
+ return map[kindColor(kind)] ?? "#9e9e9e";
+};
diff --git a/src/components/ItemTracing/traceGraphLabels.ts b/src/components/ItemTracing/traceGraphLabels.ts
new file mode 100644
index 0000000..8f5d762
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphLabels.ts
@@ -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",
+ };
+};
diff --git a/src/components/ItemTracing/traceGraphLayout.ts b/src/components/ItemTracing/traceGraphLayout.ts
new file mode 100644
index 0000000..1e2a703
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphLayout.ts
@@ -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();
+ 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();
+ 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();
+
+ 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();
+ 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();
+ 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 &
+ Partial &
+ Partial & {
+ 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,
+ };
+};
diff --git a/src/components/ItemTracing/traceGraphSearch.ts b/src/components/ItemTracing/traceGraphSearch.ts
new file mode 100644
index 0000000..62b6352
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphSearch.ts
@@ -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);
+};
diff --git a/src/components/ItemTracing/traceGraphSemantics.ts b/src/components/ItemTracing/traceGraphSemantics.ts
new file mode 100644
index 0000000..d864838
--- /dev/null
+++ b/src/components/ItemTracing/traceGraphSemantics.ts
@@ -0,0 +1,1169 @@
+import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
+import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
+import { isDoGroupChild } from "./traceDoGroupLayout";
+import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils";
+import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout";
+
+export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => {
+ if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
+ return a.sequenceIndex - b.sequenceIndex;
+};
+
+const earliestNodeInPhase = (
+ nodes: TraceGraphLayoutNode[],
+ phase: TraceGraphPhase,
+ dayKey?: string,
+): TraceGraphLayoutNode | null => {
+ let best: TraceGraphLayoutNode | null = null;
+ nodes.forEach((n) => {
+ if (n.phase !== phase) return;
+ if (dayKey != null && n.dayKey !== dayKey) return;
+ if (!best || n.sortKey < best.sortKey) best = n;
+ });
+ return best;
+};
+
+const earliestSortKey = (
+ nodes: TraceGraphLayoutNode[],
+ phase: TraceGraphPhase,
+ dayKey?: string,
+): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER;
+
+/** Sort phases by earliest event time; tie-break with canonical phase order. */
+const sortPhasesByTimestamp = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+ dayKey?: string,
+): TraceGraphPhase[] =>
+ [...phases].sort((a, b) => {
+ const ta = earliestSortKey(nodes, a, dayKey);
+ const tb = earliestSortKey(nodes, b, dayKey);
+ if (ta !== tb) return ta - tb;
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ });
+
+export const sortPhasesByEarliestEvent = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+ dayKey?: string,
+): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey);
+
+/** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */
+const sortFgPhasesForCrossDayFlow = (
+ nodes: TraceGraphLayoutNode[],
+ phases: TraceGraphPhase[],
+ phaseOrder: TraceGraphPhase[],
+): TraceGraphPhase[] =>
+ [...phases].sort((a, b) => {
+ const nodeA = earliestNodeInPhase(nodes, a);
+ const nodeB = earliestNodeInPhase(nodes, b);
+ const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER;
+ const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER;
+ const sameDay =
+ nodeA != null &&
+ nodeB != null &&
+ nodeA.dayKey !== "—" &&
+ nodeA.dayKey === nodeB.dayKey;
+ if (sameDay) {
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ }
+ if (ta !== tb) return ta - tb;
+ return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
+ });
+
+export const phaseFromKind = (
+ kind: TraceGraphNodeKind,
+ phaseOrder: TraceGraphPhase[],
+ traceLotNo?: string,
+ refType?: string | null,
+): TraceGraphPhase => {
+ const materialPrelude = Boolean(traceLotNo?.trim());
+ switch (kind) {
+ case "MATERIAL_IN":
+ return "INBOUND";
+ case "JO_CREATED":
+ // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane.
+ if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK";
+ return "INBOUND";
+ case "MATERIAL_QC":
+ case "QC":
+ case "FAIL":
+ return "QC";
+ case "MATERIAL_PICK":
+ return "MATERIAL_PICK";
+ case "PICK_GROUP":
+ return "MATERIAL_PICK";
+ case "PRODUCTION_STEP":
+ case "BYPRODUCT":
+ case "SCRAP":
+ case "DEFECT":
+ return "PRODUCTION";
+ case "OPEN":
+ return "INBOUND";
+ case "DO_OUT":
+ case "REPLENISHMENT_CREATED":
+ case "JO_OUT":
+ case "PO_OUT":
+ case "DO_GROUP":
+ case "RETURN":
+ return "OUTBOUND";
+ case "REPACK":
+ return "WAREHOUSE";
+ case "PURCHASE":
+ return "PURCHASE";
+ case "RECEIPT":
+ case "IN":
+ return "INBOUND";
+ case "PUTAWAY":
+ if (isTransferInboundPutaway(refType)) return "WAREHOUSE";
+ return "PUTAWAY";
+ case "TRANSFER":
+ return "WAREHOUSE";
+ case "ADJUSTMENT":
+ // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整).
+ return materialPrelude ? "PUTAWAY" : "WAREHOUSE";
+ case "OUT":
+ return "OUTBOUND";
+ case "STOCK_TAKE":
+ return "STOCK_TAKE";
+ case "EXPIRED":
+ case "DEPLETED":
+ return "OUTBOUND";
+ default:
+ return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0];
+ }
+};
+
+export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean =>
+ inputs.some(
+ (m) =>
+ (m.productionSteps?.length ?? 0) > 0 ||
+ (m.nestedJoPrelude?.materialInputs.length
+ ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs)
+ : false),
+ );
+
+const NO_FLOW_EDGE_SOURCE_KINDS = new Set(["EXPIRED", "DEPLETED"]);
+
+const NO_FLOW_EDGE_KINDS = new Set([
+ "REPACK",
+ "BYPRODUCT",
+]);
+
+const TERMINAL_TARGET_KINDS = new Set(["EXPIRED", "DEPLETED"]);
+
+const TERMINAL_PREDECESSOR_KINDS = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "STOCK_TAKE",
+ "REPACK",
+ "DO_OUT",
+ "DO_GROUP",
+ "JO_OUT",
+ "PO_OUT",
+ "OUT",
+ "RETURN",
+]);
+
+const nodeScopePrefix = (node: TraceGraphLayoutNode): string => {
+ const match = /^loc-(\d+)-/.exec(node.id);
+ return match ? `loc-${match[1]}-` : "";
+};
+
+const buildTerminalStateEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+) => {
+ const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind));
+ terminals.forEach((terminal) => {
+ const scope = nodeScopePrefix(terminal);
+ const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope);
+ const predecessors = scoped
+ .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id)
+ .sort(sortNodesInPhase);
+ const from = predecessors[predecessors.length - 1];
+ if (from) add(from, terminal);
+ });
+};
+
+const WAREHOUSE_FLOW_KINDS = new Set(["TRANSFER", "ADJUSTMENT"]);
+
+/** Predecessors that represent on-hand inventory state (skip QC / production). */
+const INVENTORY_STATE_KINDS = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "OUT",
+ "DO_OUT",
+ "JO_OUT",
+ "PO_OUT",
+ "DO_GROUP",
+ "STOCK_TAKE",
+ "RETURN",
+ "RECEIPT",
+ "IN",
+ "OPEN",
+]);
+
+export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean =>
+ (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) ||
+ (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) ||
+ (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) ||
+ ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) ||
+ ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim()));
+
+const materialLotKey = (n: TraceGraphLayoutNode): string | null => {
+ const lot = n.traceLotNo?.trim();
+ if (!lot) return null;
+ const item = n.traceItemCode?.trim() ?? "";
+ return `${item}::${lot}`;
+};
+
+const resolvePickFlowTarget = (
+ pick: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode => {
+ const groupId = pick.doGroupId?.trim();
+ if (!groupId) return pick;
+ return nodes.find((n) => n.id === groupId) ?? pick;
+};
+
+const PICK_OR_OUTBOUND_TARGET_KINDS = new Set([
+ "MATERIAL_PICK",
+ "PICK_GROUP",
+ "DO_OUT",
+ "DO_GROUP",
+ "JO_OUT",
+ "PO_OUT",
+ "OUT",
+]);
+
+export const shouldSkipTraceFlowEdge = (
+ from: TraceGraphLayoutNode,
+ to: TraceGraphLayoutNode,
+): boolean => {
+ if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) {
+ if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false;
+ return true;
+ }
+ // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps
+ // except material picks (handled by dedicated pick→step edges).
+ if (
+ to.kind === "PRODUCTION_STEP" &&
+ from.kind !== "MATERIAL_PICK" &&
+ from.kind !== "PICK_GROUP" &&
+ from.kind !== "PRODUCTION_STEP"
+ ) {
+ return true;
+ }
+ if (
+ (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") &&
+ to.kind === "PRODUCTION_STEP"
+ ) {
+ return true;
+ }
+ if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") {
+ return true;
+ }
+ if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) {
+ return true;
+ }
+ if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) {
+ return true;
+ }
+ if (to.kind === "REPLENISHMENT_CREATED") {
+ // Inventory / warehouse → 建立補貨; block generic phase noise.
+ return !(
+ from.kind === "PUTAWAY" ||
+ from.kind === "TRANSFER" ||
+ from.kind === "ADJUSTMENT" ||
+ from.kind === "OPEN" ||
+ from.kind === "RECEIPT" ||
+ from.kind === "IN"
+ );
+ }
+ if (from.kind === "REPLENISHMENT_CREATED") {
+ if (to.kind === "DO_GROUP") return false;
+ if (to.kind !== "DO_OUT") return true;
+ const targetSolId = from.replenishmentStockOutLineId;
+ if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) {
+ return false;
+ }
+ const doCode = from.refCode?.trim();
+ if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false;
+ return true;
+ }
+ return false;
+};
+
+const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean =>
+ nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`);
+
+const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => {
+ const code = doCode.trim();
+ if (!code) return false;
+ if (doOut.refCode?.trim() === code) return true;
+ if (doOut.title?.includes(code)) return true;
+ if (doOut.subtitle?.includes(code)) return true;
+ return (doOut.details ?? []).some((d) => d.value?.trim() === code);
+};
+
+const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge;
+
+const productionScopeKey = (n: TraceGraphLayoutNode): string =>
+ n.traceLotNo?.trim() || "__fg__";
+
+const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase();
+
+const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase();
+
+const pickSourcePutawayForTransfer = (
+ putaways: TraceGraphLayoutNode[],
+ transfer: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ const fromWh = normWh(transfer.transferFromWarehouse);
+ const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey);
+ const whMatched =
+ fromWh.length > 0
+ ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh))
+ : candidates;
+ if (!whMatched.length) return null;
+ const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER");
+ const pool = nonTransfer.length > 0 ? nonTransfer : whMatched;
+ return [...pool].sort(sortNodesInPhase).at(-1) ?? null;
+};
+
+const pickDestPutawayForTransfer = (
+ putaways: TraceGraphLayoutNode[],
+ transfer: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ const toWh = normWh(transfer.transferToWarehouse);
+ const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey);
+ const whMatched =
+ toWh.length > 0
+ ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh))
+ : candidates;
+ if (!whMatched.length) return null;
+ const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER");
+ const pool = transferPutaways.length > 0 ? transferPutaways : whMatched;
+ return [...pool].sort(sortNodesInPhase)[0] ?? null;
+};
+
+const connectPutawayToWarehousePhase = (
+ fromList: TraceGraphLayoutNode[],
+ toList: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const transfers = toList.filter((n) => n.kind === "TRANSFER");
+ transfers.forEach((tr) => {
+ const src = pickSourcePutawayForTransfer(fromList, tr);
+ if (src) add(src, tr);
+ });
+
+ const transferInbounds = toList.filter(
+ (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER",
+ );
+ transferInbounds.forEach((dest) => {
+ const src = pickSourcePutawayForTransfer(fromList, dest);
+ if (src) add(src, dest);
+ });
+
+ if (transfers.length > 0 || transferInbounds.length > 0) {
+ const linked = new Set([
+ ...transfers.map((n) => n.id),
+ ...transferInbounds.map((n) => n.id),
+ ]);
+ const remaining = toList.filter((n) => !linked.has(n.id));
+ remaining.forEach((to) => {
+ // ADJUSTMENT / other warehouse events must link from same warehouse only.
+ const from = pickUpstreamForTarget(fromList, to);
+ if (from) add(from, to);
+ });
+ return;
+ }
+
+ toList.forEach((to) => {
+ const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
+ add(from, to);
+ });
+};
+
+const buildTransferPutawayEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n));
+ const transfers = nodes.filter((n) => n.kind === "TRANSFER");
+ transfers.forEach((tr) => {
+ const src = pickSourcePutawayForTransfer(putaways, tr);
+ if (src) add(src, tr);
+ const dest = pickDestPutawayForTransfer(putaways, tr);
+ if (dest) add(tr, dest);
+ });
+
+ // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node).
+ const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER");
+ destInbound.forEach((dest) => {
+ const src = pickSourcePutawayForTransfer(putaways, dest);
+ if (src && src.id !== dest.id) add(src, dest);
+ });
+};
+
+const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [
+ "PURCHASE",
+ "INBOUND",
+ "PRODUCTION",
+ "QC",
+ "PUTAWAY",
+ "MATERIAL_PICK",
+];
+
+const buildMaterialLotFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ if (!phaseOrder.includes("MATERIAL_PICK")) return;
+
+ const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p));
+ const byLot = new Map();
+
+ nodes.forEach((n) => {
+ if (!isMaterialPreludeNode(n)) return;
+ const key = materialLotKey(n);
+ if (!key) return;
+ const list = byLot.get(key) ?? [];
+ list.push(n);
+ byLot.set(key, list);
+ });
+
+ const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
+ lotNodes
+ .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+
+ byLot.forEach((lotNodes) => {
+ const phasesPresent = sortPhasesByEarliestEvent(
+ lotNodes,
+ preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0),
+ phaseOrder,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromPhase = phasesPresent[i];
+ const toPhase = phasesPresent[i + 1];
+ const fromList = nodesInPhase(lotNodes, fromPhase);
+ const toList = nodesInPhase(lotNodes, toPhase);
+ if (!fromList.length || !toList.length) continue;
+
+ if (toPhase === "MATERIAL_PICK") {
+ const upstream = fromList[fromList.length - 1]!;
+ const seenPickTargets = new Set();
+ toList.forEach((pick) => {
+ const target = resolvePickFlowTarget(pick, nodes);
+ if (seenPickTargets.has(target.id)) return;
+ seenPickTargets.add(target.id);
+ add(upstream, target);
+ });
+ } else {
+ add(fromList[fromList.length - 1]!, toList[0]!);
+ }
+ }
+
+ // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern).
+ const putaways = lotNodes
+ .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+ const adjustments = lotNodes
+ .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind))
+ .sort(sortNodesInPhase);
+ if (putaways.length && adjustments.length) {
+ adjustments.forEach((adj) => {
+ const targetWh = adj.warehouseCode?.trim();
+ const matched = targetWh
+ ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh))
+ : putaways;
+ const from = (matched.length ? matched : putaways).at(-1);
+ if (from) add(from, adj);
+ });
+ }
+ });
+};
+
+const buildPutawayToOutboundEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const inventorySources = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ (n.kind === "PUTAWAY" ||
+ n.kind === "TRANSFER" ||
+ n.kind === "ADJUSTMENT" ||
+ n.kind === "OPEN" ||
+ n.kind === "RECEIPT" ||
+ n.kind === "IN"),
+ )
+ .sort(sortNodesInPhase);
+ if (!inventorySources.length) return;
+
+ const outboundTargets = nodes.filter(
+ (n) =>
+ !isDoGroupChild(n) &&
+ (n.kind === "DO_OUT" ||
+ n.kind === "DO_GROUP" ||
+ n.kind === "JO_OUT" ||
+ n.kind === "PO_OUT" ||
+ n.kind === "OUT" ||
+ n.kind === "REPLENISHMENT_CREATED"),
+ );
+ if (!outboundTargets.length) return;
+
+ outboundTargets.forEach((target) => {
+ const from = pickUpstreamForTarget(inventorySources, target);
+ if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target);
+ });
+};
+
+const buildPutawayToStockTakeEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const putaways = nodes
+ .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n))
+ .sort(sortNodesInPhase);
+ if (!putaways.length) return;
+
+ nodes
+ .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n))
+ .forEach((stockTake) => {
+ const scope = nodeScopePrefix(stockTake);
+ // Stock take must stay within the same inventory-lot scope (loc-* / primary).
+ const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedPutaways, stockTake);
+ if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
+ });
+};
+
+const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => {
+ if (n.kind === "TRANSFER") {
+ return n.transferToWarehouse?.trim() || n.warehouseCode?.trim();
+ }
+ return n.warehouseCode?.trim();
+};
+
+const pickUpstreamForTarget = (
+ fromList: TraceGraphLayoutNode[],
+ target: TraceGraphLayoutNode,
+): TraceGraphLayoutNode | null => {
+ if (!fromList.length) return null;
+
+ const byTime = fromList.filter(
+ (n) => n.column <= target.column || n.sortKey <= target.sortKey,
+ );
+ const pool = byTime.length > 0 ? byTime : fromList;
+
+ const targetWh = target.warehouseCode?.trim();
+ if (targetWh) {
+ const matched = pool.filter((n) =>
+ warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
+ );
+ if (matched.length) return matched[matched.length - 1]!;
+ // No warehouse match in time window — do not link across warehouses.
+ const anyMatched = fromList.filter((n) =>
+ warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
+ );
+ return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null;
+ }
+
+ const scope = nodeScopePrefix(target);
+ const scoped = pool.filter((n) => nodeScopePrefix(n) === scope);
+ if (scoped.length) return scoped[scoped.length - 1]!;
+
+ const upstreamWhs = new Set(
+ pool
+ .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase())
+ .filter(Boolean),
+ );
+ if (upstreamWhs.size <= 1) return pool[pool.length - 1]!;
+ return null;
+};
+
+const buildFgLotFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK");
+ const fgNodes = nodes.filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ );
+ if (fgNodes.length < 2) return;
+
+ const nodesInPhase = (phase: TraceGraphPhase) =>
+ fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase);
+
+ const phasesPresent = sortFgPhasesForCrossDayFlow(
+ fgNodes,
+ fgPhases.filter((p) => nodesInPhase(p).length > 0),
+ phaseOrder,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromPhase = phasesPresent[i];
+ const toPhase = phasesPresent[i + 1];
+ const fromList = nodesInPhase(fromPhase);
+ const toList = nodesInPhase(toPhase);
+ if (!fromList.length || !toList.length) continue;
+
+ if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue;
+
+ if (
+ fromPhase === "OUTBOUND" &&
+ (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE")
+ ) {
+ continue;
+ }
+
+ if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") {
+ connectPutawayToWarehousePhase(fromList, toList, add);
+ continue;
+ }
+
+ if (toPhase === "OUTBOUND") {
+ toList.forEach((out) => {
+ const scope = nodeScopePrefix(out);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedFrom, out);
+ if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out);
+ });
+ } else if (toPhase === "STOCK_TAKE") {
+ toList.forEach((stockTake) => {
+ const scope = nodeScopePrefix(stockTake);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const from = pickUpstreamForTarget(scopedFrom, stockTake);
+ if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
+ });
+ } else if (toList.length > 1) {
+ const targets =
+ toPhase === "PUTAWAY"
+ ? (() => {
+ const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER");
+ return nonTransfer.length > 0 ? nonTransfer : toList;
+ })()
+ : toList;
+ targets.forEach((to) => {
+ const from = pickUpstreamForTarget(fromList, to);
+ if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ });
+ } else {
+ const to = toList[0]!;
+ const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
+ if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ }
+ }
+};
+
+const sortProductionStepsForChain = (
+ a: TraceGraphLayoutNode,
+ b: TraceGraphLayoutNode,
+): number => {
+ const sa = a.bomProcessSeqNo;
+ const sb = b.bomProcessSeqNo;
+ if (sa != null && sb != null && sa !== sb) return sa - sb;
+ if (sa != null && sb == null) return -1;
+ if (sa == null && sb != null) return 1;
+ return sortNodesInPhase(a, b);
+};
+
+const buildMaterialPickToProductionStepEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const prodSteps = nodes.filter(
+ (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n),
+ );
+ if (!prodSteps.length) return;
+
+ const productionByScopeAndProcess = new Map();
+ prodSteps.forEach((n) => {
+ if (n.bomProcessId == null) return;
+ const key = `${productionScopeKey(n)}::${n.bomProcessId}`;
+ const list = productionByScopeAndProcess.get(key) ?? [];
+ list.push(n);
+ productionByScopeAndProcess.set(key, list);
+ });
+
+ const resolveProductionTarget = (
+ scope: string,
+ pick: TraceGraphLayoutNode,
+ ): TraceGraphLayoutNode | null => {
+ const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope;
+
+ if (pick.bomProcessId != null) {
+ const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`);
+ if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ if (pick.bomProcessSeqNo != null) {
+ const bySeq = prodSteps.filter(
+ (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo,
+ );
+ if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ const itemCode = pick.traceItemCode?.trim().toUpperCase();
+ if (itemCode) {
+ const byItem = prodSteps.filter(
+ (n) =>
+ inScope(n) &&
+ (n.stepMaterialItemCodes ?? []).some(
+ (code) => code.trim().toUpperCase() === itemCode,
+ ),
+ );
+ if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!;
+ }
+
+ return null;
+ };
+
+ const connectPickOrGroup = (
+ source: TraceGraphLayoutNode,
+ pick: TraceGraphLayoutNode,
+ seenTargets: Set,
+ ) => {
+ const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__";
+ const target = resolveProductionTarget(scope, pick);
+ if (!target || seenTargets.has(target.id)) return;
+ seenTargets.add(target.id);
+ add(
+ {
+ ...source,
+ feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo,
+ },
+ target,
+ );
+ };
+
+ nodes
+ .filter((n) => n.kind === "PICK_GROUP")
+ .forEach((group) => {
+ const children = nodes.filter(
+ (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK",
+ );
+ const seenTargets = new Set();
+ children.forEach((child) => connectPickOrGroup(group, child, seenTargets));
+ });
+
+ nodes
+ .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId)
+ .forEach((pick) => connectPickOrGroup(pick, pick, new Set()));
+};
+
+const buildProductionToFgQcEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const prodSteps = nodes.filter(
+ (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n),
+ );
+ const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n));
+ if (!prodSteps.length || !qcNodes.length) return;
+
+ const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!;
+ const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!;
+ if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc);
+};
+
+const buildProductionStepChainEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const byScope = new Map();
+ nodes
+ .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n))
+ .forEach((n) => {
+ const key = productionScopeKey(n);
+ const list = byScope.get(key) ?? [];
+ list.push(n);
+ byScope.set(key, list);
+ });
+
+ byScope.forEach((list) => {
+ const sorted = [...list].sort(sortProductionStepsForChain);
+ for (let i = 0; i < sorted.length - 1; i++) {
+ add(sorted[i]!, sorted[i + 1]!);
+ }
+ });
+};
+
+const joCodeForPickTarget = (
+ target: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): string | null => {
+ // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*.
+ const fromNode = (n: TraceGraphLayoutNode): string | null => {
+ const jo = n.jobOrderCode?.trim();
+ if (jo) return jo;
+ const conso = n.consoCode?.trim();
+ if (conso && /^JO[-_]/i.test(conso)) return conso;
+ return null;
+ };
+
+ if (target.kind === "PICK_GROUP") {
+ const direct = fromNode(target);
+ if (direct) return direct;
+ const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n));
+ return child ? fromNode(child) : null;
+ }
+ if (target.kind === "MATERIAL_PICK") {
+ return fromNode(target);
+ }
+ return null;
+};
+
+const buildJoCreatedToMaterialPickEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED");
+ if (!joCreatedNodes.length) return;
+
+ const pickTargets = nodes.filter(
+ (n) =>
+ !isDoGroupChild(n) &&
+ (n.kind === "PICK_GROUP" ||
+ (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())),
+ );
+ if (!pickTargets.length) return;
+
+ const feedLotForTarget = (target: TraceGraphLayoutNode): string => {
+ if (target.kind === "PICK_GROUP") {
+ return (
+ nodes
+ .find(
+ (n) =>
+ n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()),
+ )
+ ?.feedsProductionScopeLotNo?.trim() ?? ""
+ );
+ }
+ return target.feedsProductionScopeLotNo?.trim() ?? "";
+ };
+
+ pickTargets.forEach((target) => {
+ const pickJoCode = joCodeForPickTarget(target, nodes);
+ const matches = joCreatedNodes.filter((jo) => {
+ const joCode = jo.refCode?.trim();
+ return Boolean(joCode && pickJoCode && joCode === pickJoCode);
+ });
+ if (!matches.length) return;
+
+ const feedLot = feedLotForTarget(target);
+ let pool: TraceGraphLayoutNode[];
+ if (feedLot) {
+ const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot);
+ pool = scoped.length ? scoped : matches;
+ } else {
+ // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output.
+ const fg = matches.filter((jo) => !jo.traceLotNo?.trim());
+ pool = fg.length ? fg : matches;
+ }
+ const joCreated = [...pool].sort(sortNodesInPhase)[0]!;
+ add(joCreated, target);
+ });
+};
+
+const resolveDoOutFlowTarget = (
+ doOut: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode => {
+ const groupId = doOut.doGroupId?.trim();
+ if (!groupId) return doOut;
+ return nodes.find((n) => n.id === groupId) ?? doOut;
+};
+
+const findDoOutForReplenishment = (
+ replenishment: TraceGraphLayoutNode,
+ nodes: TraceGraphLayoutNode[],
+): TraceGraphLayoutNode | null => {
+ const stockOutLineId = replenishment.replenishmentStockOutLineId;
+ const doOuts = nodes.filter((n) => n.kind === "DO_OUT");
+ if (stockOutLineId != null) {
+ const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId));
+ if (bySol) return bySol;
+ }
+ const doCode = replenishment.refCode?.trim();
+ if (!doCode) return null;
+ const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode));
+ if (!byCode.length) return null;
+ const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish);
+ const pool = replenishFlagged.length ? replenishFlagged : byCode;
+ return [...pool].sort(sortNodesInPhase)[0] ?? null;
+};
+
+const buildReplenishmentToDoOutEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ nodes
+ .filter((n) => n.kind === "REPLENISHMENT_CREATED")
+ .forEach((replenishment) => {
+ const doOut = findDoOutForReplenishment(replenishment, nodes);
+ if (!doOut) return;
+ add(replenishment, resolveDoOutFlowTarget(doOut, nodes));
+ });
+};
+
+const connectWarehousePhaseChainEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const chainNodes = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ chainNodes
+ .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind))
+ .forEach((warehouseNode) => {
+ if (warehouseNode.kind === "TRANSFER") {
+ const putaways = chainNodes.filter(
+ (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n),
+ );
+ const src = pickSourcePutawayForTransfer(putaways, warehouseNode);
+ if (src) {
+ add(src, warehouseNode);
+ return;
+ }
+ }
+
+ const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey);
+ const targetWh = warehouseNode.warehouseCode?.trim();
+ const sameWarehouse = (n: TraceGraphLayoutNode) =>
+ !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
+
+ const warehousePredecessors = predecessors.filter(
+ (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n),
+ );
+ if (warehousePredecessors.length > 0) {
+ add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode);
+ return;
+ }
+
+ const inventoryPredecessors = predecessors.filter(
+ (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n),
+ );
+ if (inventoryPredecessors.length > 0) {
+ add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode);
+ return;
+ }
+
+ // ADJUSTMENT with a known warehouse must not fall back to a different warehouse.
+ if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return;
+
+ const lastBefore = predecessors[predecessors.length - 1];
+ if (lastBefore) add(lastBefore, warehouseNode);
+ });
+};
+
+const connectStockTakeInboundEdges = (
+ nodes: TraceGraphLayoutNode[],
+ add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
+): void => {
+ const chainNodes = nodes
+ .filter(
+ (n) =>
+ !isMaterialPreludeNode(n) &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ const inventoryPredecessorKinds = new Set([
+ "PUTAWAY",
+ "TRANSFER",
+ "ADJUSTMENT",
+ "REPACK",
+ "RECEIPT",
+ "IN",
+ "OPEN",
+ ]);
+
+ chainNodes
+ .filter((n) => n.kind === "STOCK_TAKE")
+ .forEach((stockTake) => {
+ const targetScope = nodeScopePrefix(stockTake);
+ const targetWh = stockTake.warehouseCode?.trim();
+ const targetLot = stockTake.traceLotNo?.trim();
+ const sameLot = (n: TraceGraphLayoutNode) => {
+ if (nodeScopePrefix(n) !== targetScope) return false;
+ if (!targetLot) return true;
+ const fromLot = n.traceLotNo?.trim();
+ return !fromLot || fromLot === targetLot;
+ };
+ const sameWarehouse = (n: TraceGraphLayoutNode) =>
+ !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
+
+ const predecessors = chainNodes.filter(
+ (n) =>
+ n.kind !== "STOCK_TAKE" &&
+ n.sortKey < stockTake.sortKey &&
+ sameLot(n),
+ );
+ if (!predecessors.length) return;
+
+ const putawayPredecessors = predecessors.filter(
+ (n) =>
+ n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n),
+ );
+ const inventoryPredecessors = predecessors.filter(
+ (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n),
+ );
+ const from =
+ putawayPredecessors[putawayPredecessors.length - 1] ??
+ inventoryPredecessors[inventoryPredecessors.length - 1] ??
+ // Known warehouse: never fall back across warehouses / other lots.
+ (targetWh ? undefined : predecessors[predecessors.length - 1]);
+ if (from) add(from, stockTake);
+ });
+};
+
+export const buildTraceFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+): Array<{ fromId: string; toId: string }> => {
+ const pairs: Array<{ fromId: string; toId: string }> = [];
+ const seen = new Set();
+
+ const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => {
+ if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return;
+ if (NO_FLOW_EDGE_KINDS.has(to.kind)) return;
+ const key = `${from.id}->${to.id}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ pairs.push({ fromId: from.id, toId: to.id });
+ };
+
+ const byDay = new Map();
+ nodes.forEach((n) => {
+ const list = byDay.get(n.dayKey) ?? [];
+ list.push(n);
+ byDay.set(n.dayKey, list);
+ });
+
+ const sortedDays = Array.from(byDay.keys()).sort((a, b) => {
+ if (a === "—") return 1;
+ if (b === "—") return -1;
+ return a.localeCompare(b);
+ });
+
+ const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
+ dayNodes
+ .filter(
+ (n) =>
+ n.phase === phase &&
+ !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
+ !NO_FLOW_EDGE_KINDS.has(n.kind) &&
+ !isDoGroupChild(n),
+ )
+ .sort(sortNodesInPhase);
+
+ for (const dayKey of sortedDays) {
+ const dayNodes = byDay.get(dayKey)!;
+ const phasesPresent = sortPhasesByEarliestEvent(
+ dayNodes,
+ phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0),
+ phaseOrder,
+ dayKey,
+ );
+
+ for (let i = 0; i < phasesPresent.length - 1; i++) {
+ const fromList = nodesInPhase(dayNodes, phasesPresent[i]);
+ const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]);
+ if (!fromList.length || !toList.length) continue;
+ const from = fromList[fromList.length - 1]!;
+ const to = toList[0]!;
+ if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue;
+
+ if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") {
+ continue;
+ }
+
+ if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") {
+ connectPutawayToWarehousePhase(fromList, toList, add);
+ continue;
+ }
+
+ if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") {
+ toList.forEach((target) => {
+ const scope = nodeScopePrefix(target);
+ const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
+ const matched = pickUpstreamForTarget(scopedFrom, target);
+ if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target);
+ });
+ continue;
+ }
+
+ if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
+ }
+ }
+
+ buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add);
+ buildProductionStepChainEdges(nodes, add);
+ buildProductionToFgQcEdges(nodes, add);
+ buildJoCreatedToMaterialPickEdges(nodes, add);
+ buildReplenishmentToDoOutEdges(nodes, add);
+ buildMaterialPickToProductionStepEdges(nodes, add);
+ buildFgLotFlowEdgePairs(nodes, phaseOrder, add);
+ buildPutawayToOutboundEdges(nodes, add);
+ buildPutawayToStockTakeEdges(nodes, add);
+ buildTransferPutawayEdges(nodes, add);
+ connectWarehousePhaseChainEdges(nodes, add);
+ connectStockTakeInboundEdges(nodes, add);
+ buildTerminalStateEdges(nodes, add);
+
+ return pairs;
+};
+
+export type TraceFlowEdgePair = { fromId: string; toId: string };
+
+/** Build edges from backend traceGraph when present; merge with local semantics. */
+export const resolveTraceFlowEdgePairs = (
+ nodes: TraceGraphLayoutNode[],
+ phaseOrder: TraceGraphPhase[],
+ backendEdges?: Array<{ fromKey: string; toKey: string }> | null,
+): TraceFlowEdgePair[] => {
+ const local = buildTraceFlowEdgePairs(nodes, phaseOrder);
+ if (!backendEdges?.length) return local;
+
+ const nodeByKey = new Map(nodes.map((n) => [n.id, n]));
+ const merged = new Map();
+ const addPair = (fromId: string, toId: string) => {
+ const from = nodeByKey.get(fromId);
+ const to = nodeByKey.get(toId);
+ if (!from || !to) return;
+ if (shouldSkipTraceFlowEdge(from, to)) return;
+ merged.set(`${fromId}->${toId}`, { fromId, toId });
+ };
+
+ backendEdges.forEach((e) => addPair(e.fromKey, e.toKey));
+ local.forEach((e) => addPair(e.fromId, e.toId));
+ return Array.from(merged.values());
+};
diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts
new file mode 100644
index 0000000..577d5ea
--- /dev/null
+++ b/src/components/ItemTracing/traceLabelUtils.ts
@@ -0,0 +1,178 @@
+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();
+ 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,
+});
diff --git a/src/components/ItemTracing/traceNavigationUtils.ts b/src/components/ItemTracing/traceNavigationUtils.ts
new file mode 100644
index 0000000..238e39a
--- /dev/null
+++ b/src/components/ItemTracing/traceNavigationUtils.ts
@@ -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";
+};
diff --git a/src/components/ItemTracing/traceNodeFactory.ts b/src/components/ItemTracing/traceNodeFactory.ts
new file mode 100644
index 0000000..2446c94
--- /dev/null
+++ b/src/components/ItemTracing/traceNodeFactory.ts
@@ -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,
+ 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,
+ 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 => ({
+ 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 | null => {
+ const raw = value == null ? "" : String(value).trim();
+ if (!raw) return null;
+ return field(label, raw, extra);
+};
+
+export const detailsOf = (
+ ...rows: Array
+): 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),
+ };
+};
diff --git a/src/components/ItemTracing/tracePresentationAdapter.ts b/src/components/ItemTracing/tracePresentationAdapter.ts
new file mode 100644
index 0000000..9d19b08
--- /dev/null
+++ b/src/components/ItemTracing/tracePresentationAdapter.ts
@@ -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["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),
+});
diff --git a/src/components/ItemTracing/tracePutawayUtils.ts b/src/components/ItemTracing/tracePutawayUtils.ts
new file mode 100644
index 0000000..687a4cc
--- /dev/null
+++ b/src/components/ItemTracing/tracePutawayUtils.ts
@@ -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,
+ };
+};
diff --git a/src/components/ItemTracing/traceQtyUtils.ts b/src/components/ItemTracing/traceQtyUtils.ts
new file mode 100644
index 0000000..35092d0
--- /dev/null
+++ b/src/components/ItemTracing/traceQtyUtils.ts
@@ -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;
+};
diff --git a/src/components/ItemTracing/traceStockTakeUtils.ts b/src/components/ItemTracing/traceStockTakeUtils.ts
new file mode 100644
index 0000000..ef2cc1f
--- /dev/null
+++ b/src/components/ItemTracing/traceStockTakeUtils.ts
@@ -0,0 +1,166 @@
+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;
+};
+
+/**
+ * Accepted physical qty used for variance / posting decision.
+ * Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty.
+ */
+export const resolveStockTakeAcceptedQty = (
+ detail: ItemLotTraceStockTakeRecordDetail | null | undefined,
+ fallbackAfterQty?: number | null,
+): number | null => {
+ if (detail) {
+ const pick = (n: number | null | undefined) =>
+ n != null && !Number.isNaN(Number(n)) ? Number(n) : null;
+ 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;
+ }
+ 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;
+};
diff --git a/src/components/Jodetail/JodetailSearch.tsx b/src/components/Jodetail/JodetailSearch.tsx
index ad07fe6..5a410b6 100644
--- a/src/components/Jodetail/JodetailSearch.tsx
+++ b/src/components/Jodetail/JodetailSearch.tsx
@@ -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 {
@@ -52,6 +53,20 @@ type SearchParamNames = keyof SearchQuery;
const JodetailSearch: React.FC = ({ 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;
+ })();
+ 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 +76,7 @@ const JodetailSearch: React.FC = ({ printerCombo }) => {
//const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders);
const [filterArgs, setFilterArgs] = useState>({});
const [searchQuery, setSearchQuery] = useState>({});
- const [tabIndex, setTabIndex] = useState(0);
+ const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0);
const [totalCount, setTotalCount] = useState();
const [isAssigning, setIsAssigning] = useState(false);
const [unassignedOrders, setUnassignedOrders] = useState([]);
@@ -70,6 +85,10 @@ const JodetailSearch: React.FC = ({ 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(
@@ -492,6 +511,8 @@ const JodetailSearch: React.FC = ({ printerCombo }) => {
printerCombo={printerCombo}
selectedPrinter={selectedPrinter}
printQty={printQty}
+ initialPickOrderCode={urlPickOrderCode}
+ initialTargetDate={urlTargetDate}
/>
)}
{tabIndex === 2 && }
diff --git a/src/components/Jodetail/completeJobOrderRecord.tsx b/src/components/Jodetail/completeJobOrderRecord.tsx
index 77ef206..b67f9eb 100644
--- a/src/components/Jodetail/completeJobOrderRecord.tsx
+++ b/src/components/Jodetail/completeJobOrderRecord.tsx
@@ -58,6 +58,8 @@ interface Props {
printerCombo: PrinterCombo[];
selectedPrinter?: PrinterCombo | null;
printQty?: number;
+ initialPickOrderCode?: string;
+ initialTargetDate?: string;
}
// 修改:已完成的 Job Order Pick Order 接口
@@ -112,11 +114,13 @@ interface LotDetail {
match_status: string | null;
}
-const CompleteJobOrderRecord: React.FC = ({
+const CompleteJobOrderRecord: React.FC = ({
filterArgs,
printerCombo,
selectedPrinter: selectedPrinterProp,
- printQty: printQtyProp
+ printQty: printQtyProp,
+ initialPickOrderCode,
+ initialTargetDate,
}) => {
const { t } = useTranslation("jo");
const router = useRouter();
@@ -135,15 +139,23 @@ const CompleteJobOrderRecord: React.FC = ({
const [detailLotDataLoading, setDetailLotDataLoading] = useState(false);
// 修改:搜索状态
- const [searchQuery, setSearchQuery] = useState>(() => ({
- completedDate: dayjs().format("YYYY-MM-DD"),
- }));
+ const [searchQuery, setSearchQuery] = useState>(() => {
+ 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([]);
// 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 +382,25 @@ const CompleteJobOrderRecord: React.FC = ({
}, [fetchLotDetailsData]);
+ useEffect(() => {
+ 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);
+ }, [
+ completedJobOrderPickOrders,
+ completedJobOrderPickOrdersLoading,
+ initialPickOrderCode,
+ handleDetailClick,
+ ]);
+
// 修改:返回列表视图
const handleBackToList = useCallback(() => {
setShowDetailView(false);
diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx
index 426db03..314e431 100644
--- a/src/components/NavigationContent/NavigationContent.tsx
+++ b/src/components/NavigationContent/NavigationContent.tsx
@@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => {
icon: ,
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: ,
+ labelKey: "nav.store.itemTracing",
+ requiredAbility: [AUTH.ITEM_TRACING],
+ path: "/itemTracing",
+ },
{
id: "nav.store.stockTake",
icon: ,
diff --git a/src/i18n/en/itemTracing.json b/src/i18n/en/itemTracing.json
new file mode 100644
index 0000000..1763fc7
--- /dev/null
+++ b/src/i18n/en/itemTracing.json
@@ -0,0 +1,312 @@
+{
+ "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",
+ "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",
+ "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.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"
+}
diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json
index 61a67c7..7d852f0 100644
--- a/src/i18n/en/navigation.json
+++ b/src/i18n/en/navigation.json
@@ -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",
diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json
new file mode 100644
index 0000000..6aefb9f
--- /dev/null
+++ b/src/i18n/zh/itemTracing.json
@@ -0,0 +1,313 @@
+{
+ "title": "批號追溯",
+ "subtitle": "掃描批號 QR 即可追溯完整生命週期:入庫、出庫、品檢、提料單、工單、盤點、轉倉及 BOM 關聯。",
+ "scanReady": "掃碼槍就緒 — 請掃描批號標籤 QR",
+ "scanning": "掃描中…",
+ "scanAgain": "重新掃描",
+ "manualSearch": "手動查詢",
+ "itemCode": "貨品編號",
+ "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.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": "最近異動"
+}
diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json
index 4d56587..cd01b47 100644
--- a/src/i18n/zh/navigation.json
+++ b/src/i18n/zh/navigation.json
@@ -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": "上架掃碼",
diff --git a/src/utils/traceDoOutboundExtra.ts b/src/utils/traceDoOutboundExtra.ts
new file mode 100644
index 0000000..54711ac
--- /dev/null
+++ b/src/utils/traceDoOutboundExtra.ts
@@ -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;
+};