From 8f7ff13997a462f87b3f46b36b51d6904d27c9f8 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 17 Jul 2026 12:46:33 +0800 Subject: [PATCH 01/16] item trace --- src/app/(main)/itemTracing/page.tsx | 44 + src/app/api/itemTracing/actions.ts | 24 + src/app/api/itemTracing/index.ts | 493 +++++++ src/app/api/itemTracing/schema.ts | 211 +++ src/app/api/jo/actions.ts | 11 +- src/app/utils/fetchUtil.ts | 13 +- src/app/utils/serverFetchError.ts | 21 + src/authorities.ts | 1 + src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../DoWorkbench/DoWorkbenchTabs.tsx | 7 +- .../GoodPickExecutionWorkbenchRecord.tsx | 12 +- src/components/ItemTracing/ItemTracing.tsx | 151 ++ .../ItemTracing/ItemTracingDocLink.tsx | 90 ++ .../ItemTracing/ItemTracingFlowGraph.tsx | 797 +++++++++++ .../ItemTracingFlowGraphSearch.tsx | 158 +++ .../ItemTracing/ItemTracingLoading.tsx | 11 + .../ItemTracing/ItemTracingLocations.tsx | 790 +++++++++++ .../ItemTracing/ItemTracingLotTraceLink.tsx | 50 + .../ItemTracingNodeDetailPanel.tsx | 265 ++++ .../ItemTracing/ItemTracingScanBar.tsx | 142 ++ .../ItemTracing/ItemTracingSections.tsx | 915 +++++++++++++ .../ItemTracingStockTakeLifecycle.tsx | 100 ++ .../ItemTracing/ItemTracingSummary.tsx | 291 ++++ src/components/ItemTracing/TraceFlowEdge.tsx | 58 + src/components/ItemTracing/TraceFlowNodes.tsx | 843 ++++++++++++ .../buildExtendedTraceGraphNodes.ts | 277 ++++ .../ItemTracing/buildJoPreludeGraphNodes.ts | 632 +++++++++ .../buildLocationBlockGraphNodes.ts | 172 +++ .../ItemTracing/buildProductionGraphNodes.ts | 169 +++ .../ItemTracing/buildReactFlowGraph.ts | 323 +++++ .../ItemTracing/buildTraceGraphNodes.ts | 1210 +++++++++++++++++ .../ItemTracing/compileTraceGraph.ts | 33 + .../ItemTracing/exportItemLotTraceXlsx.ts | 905 ++++++++++++ src/components/ItemTracing/index.ts | 5 + .../ItemTracing/itemTracingTableFilters.tsx | 113 ++ .../ItemTracing/mergeLocationScopedData.ts | 316 +++++ .../ItemTracing/traceDoGroupLayout.ts | 274 ++++ .../ItemTracing/traceDocLinkUtils.ts | 155 +++ .../ItemTracing/traceFlowConstants.ts | 38 + .../ItemTracing/traceFlowEdgeLayout.ts | 395 ++++++ src/components/ItemTracing/traceFlowLayout.ts | 200 +++ .../ItemTracing/traceFlowNodeUtils.ts | 223 +++ .../ItemTracing/traceGraphLabels.ts | 291 ++++ .../ItemTracing/traceGraphLayout.ts | 402 ++++++ .../ItemTracing/traceGraphSearch.ts | 47 + .../ItemTracing/traceGraphSemantics.ts | 1169 ++++++++++++++++ src/components/ItemTracing/traceLabelUtils.ts | 178 +++ .../ItemTracing/traceNavigationUtils.ts | 14 + .../ItemTracing/traceNodeFactory.ts | 213 +++ .../ItemTracing/tracePresentationAdapter.ts | 200 +++ .../ItemTracing/tracePutawayUtils.ts | 174 +++ src/components/ItemTracing/traceQtyUtils.ts | 38 + .../ItemTracing/traceStockTakeUtils.ts | 166 +++ src/components/Jodetail/JodetailSearch.tsx | 23 +- .../Jodetail/completeJobOrderRecord.tsx | 41 +- .../NavigationContent/NavigationContent.tsx | 9 +- src/i18n/en/itemTracing.json | 312 +++++ src/i18n/en/navigation.json | 2 + src/i18n/zh/itemTracing.json | 313 +++++ src/i18n/zh/navigation.json | 2 + src/utils/traceDoOutboundExtra.ts | 57 + 61 files changed, 14568 insertions(+), 22 deletions(-) create mode 100644 src/app/(main)/itemTracing/page.tsx create mode 100644 src/app/api/itemTracing/actions.ts create mode 100644 src/app/api/itemTracing/index.ts create mode 100644 src/app/api/itemTracing/schema.ts create mode 100644 src/app/utils/serverFetchError.ts create mode 100644 src/components/ItemTracing/ItemTracing.tsx create mode 100644 src/components/ItemTracing/ItemTracingDocLink.tsx create mode 100644 src/components/ItemTracing/ItemTracingFlowGraph.tsx create mode 100644 src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx create mode 100644 src/components/ItemTracing/ItemTracingLoading.tsx create mode 100644 src/components/ItemTracing/ItemTracingLocations.tsx create mode 100644 src/components/ItemTracing/ItemTracingLotTraceLink.tsx create mode 100644 src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx create mode 100644 src/components/ItemTracing/ItemTracingScanBar.tsx create mode 100644 src/components/ItemTracing/ItemTracingSections.tsx create mode 100644 src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx create mode 100644 src/components/ItemTracing/ItemTracingSummary.tsx create mode 100644 src/components/ItemTracing/TraceFlowEdge.tsx create mode 100644 src/components/ItemTracing/TraceFlowNodes.tsx create mode 100644 src/components/ItemTracing/buildExtendedTraceGraphNodes.ts create mode 100644 src/components/ItemTracing/buildJoPreludeGraphNodes.ts create mode 100644 src/components/ItemTracing/buildLocationBlockGraphNodes.ts create mode 100644 src/components/ItemTracing/buildProductionGraphNodes.ts create mode 100644 src/components/ItemTracing/buildReactFlowGraph.ts create mode 100644 src/components/ItemTracing/buildTraceGraphNodes.ts create mode 100644 src/components/ItemTracing/compileTraceGraph.ts create mode 100644 src/components/ItemTracing/exportItemLotTraceXlsx.ts create mode 100644 src/components/ItemTracing/index.ts create mode 100644 src/components/ItemTracing/itemTracingTableFilters.tsx create mode 100644 src/components/ItemTracing/mergeLocationScopedData.ts create mode 100644 src/components/ItemTracing/traceDoGroupLayout.ts create mode 100644 src/components/ItemTracing/traceDocLinkUtils.ts create mode 100644 src/components/ItemTracing/traceFlowConstants.ts create mode 100644 src/components/ItemTracing/traceFlowEdgeLayout.ts create mode 100644 src/components/ItemTracing/traceFlowLayout.ts create mode 100644 src/components/ItemTracing/traceFlowNodeUtils.ts create mode 100644 src/components/ItemTracing/traceGraphLabels.ts create mode 100644 src/components/ItemTracing/traceGraphLayout.ts create mode 100644 src/components/ItemTracing/traceGraphSearch.ts create mode 100644 src/components/ItemTracing/traceGraphSemantics.ts create mode 100644 src/components/ItemTracing/traceLabelUtils.ts create mode 100644 src/components/ItemTracing/traceNavigationUtils.ts create mode 100644 src/components/ItemTracing/traceNodeFactory.ts create mode 100644 src/components/ItemTracing/tracePresentationAdapter.ts create mode 100644 src/components/ItemTracing/tracePutawayUtils.ts create mode 100644 src/components/ItemTracing/traceQtyUtils.ts create mode 100644 src/components/ItemTracing/traceStockTakeUtils.ts create mode 100644 src/i18n/en/itemTracing.json create mode 100644 src/i18n/zh/itemTracing.json create mode 100644 src/utils/traceDoOutboundExtra.ts 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 ( + + + + + {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 + /> + + + + + ); +}; + +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; +}; From a71d44991ebaa0f1e7c132a0d7283261ef783d9e Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 17 Jul 2026 16:40:23 +0800 Subject: [PATCH 02/16] add ref no --- src/components/ItemTracing/ItemTracingDocLink.tsx | 2 +- src/components/ItemTracing/ItemTracingFlowGraph.tsx | 2 +- src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx | 2 +- src/components/ItemTracing/ItemTracingLocations.tsx | 2 +- src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx | 2 +- src/components/ItemTracing/ItemTracingScanBar.tsx | 2 +- src/components/ItemTracing/ItemTracingSections.tsx | 2 +- src/components/ItemTracing/ItemTracingSummary.tsx | 2 +- src/components/ItemTracing/exportItemLotTraceXlsx.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx index 2aabc2d..44a198d 100644 --- a/src/components/ItemTracing/ItemTracingDocLink.tsx +++ b/src/components/ItemTracing/ItemTracingDocLink.tsx @@ -23,7 +23,7 @@ const stopGraphEvent = (e: React.MouseEvent) => { e.stopPropagation(); }; -/** FP-MTMS Version Checklist | Functions Ref. No. 5 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-17 */ const ItemTracingDocLink: React.FC = ({ kind, code, diff --git a/src/components/ItemTracing/ItemTracingFlowGraph.tsx b/src/components/ItemTracing/ItemTracingFlowGraph.tsx index b903af8..6fbff90 100644 --- a/src/components/ItemTracing/ItemTracingFlowGraph.tsx +++ b/src/components/ItemTracing/ItemTracingFlowGraph.tsx @@ -787,7 +787,7 @@ const ItemTracingFlowGraphInner: React.FC = ({ ); }; -/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 14 | v1.0.0 | 2026-07-17 */ const ItemTracingFlowGraph: React.FC = (props) => ( diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx index 169b0eb..7d8e93a 100644 --- a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx +++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx @@ -25,7 +25,7 @@ type Props = { onClear: () => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ const ItemTracingFlowGraphSearch: React.FC = ({ query, matchCount, diff --git a/src/components/ItemTracing/ItemTracingLocations.tsx b/src/components/ItemTracing/ItemTracingLocations.tsx index d768102..68bc491 100644 --- a/src/components/ItemTracing/ItemTracingLocations.tsx +++ b/src/components/ItemTracing/ItemTracingLocations.tsx @@ -69,7 +69,7 @@ function SectionTable({ ); } -/** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */ const ItemTracingLocations: React.FC = ({ locationBlocks, onFocusWarehouse, diff --git a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx index 3d22d7a..6fb9422 100644 --- a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx +++ b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx @@ -30,7 +30,7 @@ type Props = { onClose?: () => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ const ItemTracingNodeDetailPanel: React.FC = ({ node, onClose }) => { const { t } = useTranslation("itemTracing"); diff --git a/src/components/ItemTracing/ItemTracingScanBar.tsx b/src/components/ItemTracing/ItemTracingScanBar.tsx index abcceb2..40739c6 100644 --- a/src/components/ItemTracing/ItemTracingScanBar.tsx +++ b/src/components/ItemTracing/ItemTracingScanBar.tsx @@ -25,7 +25,7 @@ type ScanBarProps = { lastLotNo?: string; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 1 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 12 | v1.0.0 | 2026-07-17 */ const ItemTracingScanBar: React.FC = ({ onTrace, loading, diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx index 40df2d6..fe2b9d8 100644 --- a/src/components/ItemTracing/ItemTracingSections.tsx +++ b/src/components/ItemTracing/ItemTracingSections.tsx @@ -46,7 +46,7 @@ type Props = { onTrace?: (params: TraceParams) => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 6 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-17 */ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) => { const { t } = useTranslation("itemTracing"); const [tab, setTab] = useState(0); diff --git a/src/components/ItemTracing/ItemTracingSummary.tsx b/src/components/ItemTracing/ItemTracingSummary.tsx index bfc8299..2781126 100644 --- a/src/components/ItemTracing/ItemTracingSummary.tsx +++ b/src/components/ItemTracing/ItemTracingSummary.tsx @@ -38,7 +38,7 @@ type SummaryWarehouseRow = { status: string; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */ const ItemTracingSummary: React.FC = ({ data, onFocusWarehouse, diff --git a/src/components/ItemTracing/exportItemLotTraceXlsx.ts b/src/components/ItemTracing/exportItemLotTraceXlsx.ts index e065953..d8776f3 100644 --- a/src/components/ItemTracing/exportItemLotTraceXlsx.ts +++ b/src/components/ItemTracing/exportItemLotTraceXlsx.ts @@ -889,7 +889,7 @@ export const buildItemLotTraceSheets = ( export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); -/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-15 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ export const exportItemLotTraceXlsx = ( data: ItemLotTraceResponse, compiledGraph: CompiledTraceGraph, From 44ed414f684aa1cfaa7e7868924e1dc33b72fe69 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 17 Jul 2026 16:45:09 +0800 Subject: [PATCH 03/16] no message --- package.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ccd8e66..c9f209c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@emotion/cache": "^11.11.0", @@ -40,6 +42,7 @@ "@tiptap/react": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", "@unly/universal-language-detector": "^2.0.3", + "@xyflow/react": "^12.11.1", "apexcharts": "^3.45.2", "axios": "^1.9.0", "dayjs": "^1.11.10", @@ -67,7 +70,8 @@ "styled-components": "^6.1.8", "sweetalert2": "^11.10.3", "xlsx": "^0.18.5", - "xlsx-js-style": "^1.2.0" + "xlsx-js-style": "^1.2.0", + "zod": "^3.24.2" }, "devDependencies": { "@types/lodash": "^4.14.202", @@ -85,6 +89,7 @@ "postcss": "^8.4.33", "prettier": "3.1.1", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.4" } } From 0aaa84b8cbede838beae3b19b3cc48f95ff211d7 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 17 Jul 2026 16:58:35 +0800 Subject: [PATCH 04/16] DO THIS: npm install zod --legacy-peer-deps npm install @xyflow/react --legacy-peer-deps --- src/components/ItemTracing/ItemTracingLocations.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ItemTracing/ItemTracingLocations.tsx b/src/components/ItemTracing/ItemTracingLocations.tsx index 68bc491..a3d460d 100644 --- a/src/components/ItemTracing/ItemTracingLocations.tsx +++ b/src/components/ItemTracing/ItemTracingLocations.tsx @@ -69,6 +69,7 @@ function SectionTable({ ); } + /** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */ const ItemTracingLocations: React.FC = ({ locationBlocks, From f0c3faa85ba03139defebc3bf2439dd0f7a0b24c Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 17 Jul 2026 17:16:22 +0800 Subject: [PATCH 05/16] newer version of supporting lib --- package-lock.json | 2299 +++++++++++++++++++++++++++++++++++---------- package.json | 4 +- 2 files changed, 1820 insertions(+), 483 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69fa9aa..67eda50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@tiptap/react": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", "@unly/universal-language-detector": "^2.0.3", + "@xyflow/react": "^12.11.2", "apexcharts": "^3.45.2", "axios": "^1.9.0", "dayjs": "^1.11.10", @@ -65,7 +66,8 @@ "styled-components": "^6.1.8", "sweetalert2": "^11.10.3", "xlsx": "^0.18.5", - "xlsx-js-style": "^1.2.0" + "xlsx-js-style": "^1.2.0", + "zod": "^3.25.76" }, "devDependencies": { "@types/lodash": "^4.14.202", @@ -83,7 +85,8 @@ "postcss": "^8.4.33", "prettier": "3.1.1", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.4" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1929,6 +1932,448 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2275,9 +2720,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -2920,94 +3366,444 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", - "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rollup/pluginutils/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rushstack/eslint-patch": { "version": "1.7.2", @@ -3569,34 +4365,79 @@ "url": "https://github.com/sponsors/ueberdosis" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", - "peer": true, "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" + "@types/d3-selection": "*" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "license": "MIT", - "peer": true + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" }, "node_modules/@types/glob": { "version": "7.2.0", @@ -3974,181 +4815,193 @@ "resolved": "https://registry.npmjs.org/@unly/utils/-/utils-1.0.3.tgz", "integrity": "sha512-QTRknIDX56FvzGcIpBum5D/oRSlX3dkZ+l1op1jsFlYCTd925OGUb991V7zsFv3ePcqFfvfqfR5cNVv+w4JAOw==" }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@types/estree": "^1.0.0" + } }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@xtuc/ieee754": "^1.2.0" + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "peer": true, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", "dependencies": { - "@xtuc/long": "4.2.2" + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", @@ -4184,24 +5037,11 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" + "bin": { + "acorn": "bin/acorn" }, - "peerDependencies": { - "acorn": "^8.14.0" + "engines": { + "node": ">=0.4.0" } }, "node_modules/acorn-jsx": { @@ -4546,6 +5386,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4848,6 +5698,16 @@ "node": ">=10.16.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -4951,6 +5811,23 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4972,6 +5849,16 @@ "node": ">=0.8.0" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4996,15 +5883,11 @@ "fsevents": "~2.3.2" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" }, "node_modules/classnames": { "version": "2.5.1", @@ -5215,6 +6098,111 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5227,11 +6215,12 @@ "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5242,6 +6231,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5476,6 +6475,7 @@ "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -5608,13 +6608,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5665,6 +6658,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6283,6 +7318,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -6294,6 +7330,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -6311,16 +7348,6 @@ "node": ">=0.10.0" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/exit-on-epipe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", @@ -6330,6 +7357,16 @@ "node": ">=0.8" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6391,6 +7428,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fflate": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", @@ -7808,20 +8863,6 @@ "uc.micro": "^2.0.0" } }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -7903,6 +8944,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -8067,9 +9115,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/mui-color-input": { "version": "7.0.0", @@ -8105,15 +9154,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8127,13 +9177,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true - }, "node_modules/next": { "version": "14.0.4", "resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz", @@ -8601,6 +9644,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8723,9 +9783,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -8741,10 +9801,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -9955,6 +11016,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9990,9 +11058,10 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10032,6 +11101,20 @@ "node": ">=0.8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -10227,6 +11310,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-components": { "version": "6.1.8", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz", @@ -10572,6 +11675,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10732,25 +11836,99 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, - "dependencies": { - "any-promise": "^1.0.0" + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "license": "MIT", "engines": { - "node": ">=0.8" + "node": ">=14.0.0" } }, "node_modules/tippy.js": { @@ -10929,7 +12107,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11100,191 +12278,291 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { - "webpack": "bin/webpack.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=10.13.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "webpack-cli": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, "engines": { - "node": ">=10.13.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "engines": { + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/vite/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.3" + "@types/estree": "1.0.9" }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=8.0.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" } }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "peer": true, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=4.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, "engines": { - "node": ">= 10.13.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/webpack/node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "peer": true, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -11293,6 +12571,11 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -11392,6 +12675,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -11909,6 +13209,43 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index c9f209c..3a630eb 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@tiptap/react": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", "@unly/universal-language-detector": "^2.0.3", - "@xyflow/react": "^12.11.1", + "@xyflow/react": "^12.11.2", "apexcharts": "^3.45.2", "axios": "^1.9.0", "dayjs": "^1.11.10", @@ -71,7 +71,7 @@ "sweetalert2": "^11.10.3", "xlsx": "^0.18.5", "xlsx-js-style": "^1.2.0", - "zod": "^3.24.2" + "zod": "^3.25.76" }, "devDependencies": { "@types/lodash": "^4.14.202", From 245b86e463b86a84e06622bf173562b15b6be8be Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 17 Jul 2026 21:53:33 +0800 Subject: [PATCH 06/16] inventory search update --- src/app/api/inventory/actions.ts | 16 ++++++++++++++++ .../InventorySearch/InventorySearch.tsx | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 0f10e48..417c1b3 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -160,13 +160,29 @@ async function fetchInventoriesImpl(data: SearchInventory) { ); } +async function fetchInventoriesLatestImpl(data: SearchInventory) { + const queryStr = convertObjToURLSearchParams(data); + return serverFetchJson( + `${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`, + { next: { tags: ["inventories"] } }, + ); +} + export const fetchInventories = cache(fetchInventoriesImpl); +/** Inventory search page: latest inventory row per item (no baseUnit/uomId filter). */ +export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); + /** Bypass React cache() after mutations so lists show fresh qty. */ export async function fetchInventoriesFresh(data: SearchInventory) { return fetchInventoriesImpl(data); } +/** Bypass React cache() for inventory search page latest-inventory search. */ +export async function fetchInventoriesLatestFresh(data: SearchInventory) { + return fetchInventoriesLatestImpl(data); +} + async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { const queryStr = convertObjToURLSearchParams(data); return serverFetchJson( diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 33becd7..8909955 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -12,7 +12,7 @@ import { analyzeQrCode, SearchInventory, SearchInventoryLotLine, - fetchInventories, + fetchInventoriesLatest, fetchInventoryLotLines, } from '@/app/api/inventory/actions'; import { PrinterCombo } from '@/app/api/settings/printer'; @@ -187,7 +187,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { pageSize: pagingController.pageSize, }; - const response = await fetchInventories(params); + const response = await fetchInventoriesLatest(params); if (response) { setInventoriesTotalCount(response.total); @@ -199,7 +199,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { break; case 'paging': setFilteredInventories((fi) => - uniqBy([...fi, ...response.records], 'id'), + uniqBy([...fi, ...response.records], 'itemId'), ); } } From f5c7478a2769af7e9ed42c82623fe2e18818e464 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sat, 18 Jul 2026 00:56:17 +0800 Subject: [PATCH 07/16] item trace type fix --- src/components/ItemTracing/buildTraceGraphNodes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts index 9ee7d78..b23120e 100644 --- a/src/components/ItemTracing/buildTraceGraphNodes.ts +++ b/src/components/ItemTracing/buildTraceGraphNodes.ts @@ -1035,7 +1035,7 @@ export const buildTraceGraphNodes = ( sortKey: parseSortKey(e.timestamp, seq++), title: labels.nodeStockTake, subtitle: stockTakeEventSubtitle(e), - qty: acceptedQty, + qty: acceptedQty ?? undefined, uom: stockUom, meta: meta || undefined, refCode: e.stockTakeCode, From 9913e9a8d8ac48dfcb019403e3b8c1f9f1db8732 Mon Sep 17 00:00:00 2001 From: tommy Date: Sun, 19 Jul 2026 03:55:34 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix=20=E8=87=AA=E5=8B=95=E8=B7=B3?= =?UTF-8?q?=E8=BD=89bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DoWorkbench/DoWorkbenchTabs.tsx | 11 ++++++++--- .../DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx | 6 +++++- src/components/ItemTracing/ItemTracingDocLink.tsx | 4 ++++ src/components/Jodetail/JodetailSearch.tsx | 3 +++ src/components/Jodetail/completeJobOrderRecord.tsx | 5 +++++ 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 05c24a8..20eef11 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -74,6 +74,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom urlTargetDateRaw && urlTargetDateRaw.trim() !== "" ? decodeURIComponent(urlTargetDateRaw.trim()) : null; + /** Item Tracing deep-link only; DO finish redirect must not set this. */ + const urlOpenDetail = searchParams.get("openDetail") === "1"; const [tab, setTab] = React.useState(defaultTabIndex); const [lanePanelPrefs, setLanePanelPrefs] = React.useState( @@ -147,10 +149,11 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom setTab(newTab); const params = new URLSearchParams(searchParams.toString()); params.set("tab", String(newTab)); - /* ticketNo / targetDate deep-link for Finished Good Record tabs */ + /* ticketNo / targetDate / openDetail deep-link for Finished Good Record tabs */ if (newTab !== 2 && newTab !== 3) { params.delete("ticketNo"); params.delete("targetDate"); + params.delete("openDetail"); } const qs = params.toString(); router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); @@ -380,24 +383,26 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom
diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index bec0322..c304353 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -48,6 +48,8 @@ type Props = { labelPrinter: PrinterCombo | null; initialTicketNo?: string | null; initialTargetDate?: string | null; + /** When true (Item Tracing `openDetail=1`), auto-open matching ticket detail. */ + openDetail?: boolean; }; const GoodPickExecutionWorkbenchRecord: React.FC = ({ @@ -57,6 +59,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ labelPrinter, initialTicketNo, initialTargetDate, + openDetail = false, }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -509,13 +512,14 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ const initialDetailOpenedRef = useRef(false); useEffect(() => { + if (!openDetail) return; if (initialDetailOpenedRef.current || loading || !initialTicketNo?.trim()) return; const tn = initialTicketNo.trim(); const match = records.find((r) => r.ticketNo?.trim() === tn); if (!match) return; initialDetailOpenedRef.current = true; void handleDetailClick(match); - }, [records, loading, initialTicketNo, handleDetailClick]); + }, [openDetail, records, loading, initialTicketNo, handleDetailClick]); const handleBackToList = useCallback(() => { setShowDetailView(false); diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx index 44a198d..118b064 100644 --- a/src/components/ItemTracing/ItemTracingDocLink.tsx +++ b/src/components/ItemTracing/ItemTracingDocLink.tsx @@ -47,6 +47,8 @@ const ItemTracingDocLink: React.FC = ({ params.set("tab", String(workbenchTab)); params.set("ticketNo", ticketNo.trim()); if (linkTargetDate) params.set("targetDate", linkTargetDate); + // Only Item Tracing deep-links should auto-open detail (not DO finish redirect). + params.set("openDetail", "1"); href = `/doworkbench?${params.toString()}`; } else if (kind === "jodetail" && (consoCode || code)) { const params = new URLSearchParams(); @@ -54,6 +56,7 @@ const ItemTracingDocLink: React.FC = ({ // Prefer PI-* pick order code for jodetail search; consoCode may be PICK-*. params.set("pickOrderCode", (code || consoCode || "").trim()); if (linkTargetDate) params.set("targetDate", linkTargetDate); + params.set("openDetail", "1"); href = `/jodetail?${params.toString()}`; } else if (kind === "pick") { // /pickOrder/detail?consoCode=… is retired — only link when we have a TI-* workbench ticket. @@ -65,6 +68,7 @@ const ItemTracingDocLink: React.FC = ({ params.set("tab", String(workbenchTab)); params.set("ticketNo", workbenchTicket); if (linkTargetDate) params.set("targetDate", linkTargetDate); + params.set("openDetail", "1"); href = `/doworkbench?${params.toString()}`; } else { return <>{code}; diff --git a/src/components/Jodetail/JodetailSearch.tsx b/src/components/Jodetail/JodetailSearch.tsx index 5a410b6..d0535af 100644 --- a/src/components/Jodetail/JodetailSearch.tsx +++ b/src/components/Jodetail/JodetailSearch.tsx @@ -62,6 +62,8 @@ const JodetailSearch: React.FC = ({ printerCombo }) => { const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/); return match ? match[1] : undefined; })(); + /** Item Tracing deep-link only; other navigations must not set this. */ + const urlOpenDetail = searchParams.get("openDetail") === "1"; const urlTabIndex = useMemo(() => { if (urlTabRaw == null) return undefined; const n = parseInt(urlTabRaw, 10); @@ -513,6 +515,7 @@ const JodetailSearch: React.FC = ({ printerCombo }) => { printQty={printQty} initialPickOrderCode={urlPickOrderCode} initialTargetDate={urlTargetDate} + openDetail={urlOpenDetail} /> )} {tabIndex === 2 && } diff --git a/src/components/Jodetail/completeJobOrderRecord.tsx b/src/components/Jodetail/completeJobOrderRecord.tsx index b67f9eb..939fe18 100644 --- a/src/components/Jodetail/completeJobOrderRecord.tsx +++ b/src/components/Jodetail/completeJobOrderRecord.tsx @@ -60,6 +60,8 @@ interface Props { printQty?: number; initialPickOrderCode?: string; initialTargetDate?: string; + /** When true (Item Tracing `openDetail=1`), auto-open matching pick-order detail. */ + openDetail?: boolean; } // 修改:已完成的 Job Order Pick Order 接口 @@ -121,6 +123,7 @@ const CompleteJobOrderRecord: React.FC = ({ printQty: printQtyProp, initialPickOrderCode, initialTargetDate, + openDetail = false, }) => { const { t } = useTranslation("jo"); const router = useRouter(); @@ -383,6 +386,7 @@ const CompleteJobOrderRecord: React.FC = ({ }, [fetchLotDetailsData]); useEffect(() => { + if (!openDetail) return; if (initialDetailOpenedRef.current || completedJobOrderPickOrdersLoading) return; const code = initialPickOrderCode?.trim(); if (!code) return; @@ -395,6 +399,7 @@ const CompleteJobOrderRecord: React.FC = ({ initialDetailOpenedRef.current = true; void handleDetailClick(match); }, [ + openDetail, completedJobOrderPickOrders, completedJobOrderPickOrdersLoading, initialPickOrderCode, From 082643aa03d0aecd4ca61112981108d6045a0f54 Mon Sep 17 00:00:00 2001 From: tommy Date: Sun, 19 Jul 2026 04:16:05 +0800 Subject: [PATCH 09/16] no message --- src/components/DoWorkbench/DoWorkbenchTabs.tsx | 2 ++ src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx | 1 + src/components/ItemTracing/ItemTracingDocLink.tsx | 2 +- src/components/Jodetail/JodetailSearch.tsx | 1 + src/components/Jodetail/completeJobOrderRecord.tsx | 1 + 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 20eef11..5c85e0c 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -58,6 +58,7 @@ function TabPanel(props: { value: number; index: number; children: React.ReactNo return {children}; } +/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCombo = [] }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -419,6 +420,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom ); }; +/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ const DoWorkbenchTabs: React.FC = (props) => ( = ({ printerCombo, listScope = "mine", diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx index 118b064..e4a2e1b 100644 --- a/src/components/ItemTracing/ItemTracingDocLink.tsx +++ b/src/components/ItemTracing/ItemTracingDocLink.tsx @@ -23,7 +23,7 @@ const stopGraphEvent = (e: React.MouseEvent) => { e.stopPropagation(); }; -/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.0 | 2026-07-17 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.1 | 2026-07-19 */ const ItemTracingDocLink: React.FC = ({ kind, code, diff --git a/src/components/Jodetail/JodetailSearch.tsx b/src/components/Jodetail/JodetailSearch.tsx index d0535af..f34ddc2 100644 --- a/src/components/Jodetail/JodetailSearch.tsx +++ b/src/components/Jodetail/JodetailSearch.tsx @@ -51,6 +51,7 @@ type SearchQuery = Partial< type SearchParamNames = keyof SearchQuery; +/** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */ const JodetailSearch: React.FC = ({ printerCombo }) => { const { t } = useTranslation("jo"); const searchParams = useSearchParams(); diff --git a/src/components/Jodetail/completeJobOrderRecord.tsx b/src/components/Jodetail/completeJobOrderRecord.tsx index 939fe18..ae9a03e 100644 --- a/src/components/Jodetail/completeJobOrderRecord.tsx +++ b/src/components/Jodetail/completeJobOrderRecord.tsx @@ -116,6 +116,7 @@ interface LotDetail { match_status: string | null; } +/** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */ const CompleteJobOrderRecord: React.FC = ({ filterArgs, printerCombo, From f906a9e747762be94a9f0a53fbe5392d030fee71 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 20 Jul 2026 18:44:10 +0800 Subject: [PATCH 10/16] item trace , drink produce dashboard --- src/app/api/itemTracing/index.ts | 5 +- src/app/api/jo/actions.ts | 30 ++ .../ItemTracingFlowGraphSearch.tsx | 27 +- .../ItemTracing/ItemTracingSections.tsx | 31 +- src/components/ItemTracing/TraceFlowNodes.tsx | 38 +- .../buildExtendedTraceGraphNodes.ts | 1 + .../ItemTracing/buildTraceGraphNodes.ts | 20 +- src/components/ItemTracing/traceLabelUtils.ts | 13 + .../ItemTracing/traceStockTakeUtils.ts | 13 +- .../DrinkProductionQtyDashboard.tsx | 349 ++++++++++++++++++ .../ProductionProcessPage.tsx | 8 +- src/i18n/en/itemTracing.json | 9 + src/i18n/en/jo.json | 2 + src/i18n/en/productionProcess.json | 5 + src/i18n/zh/itemTracing.json | 8 + src/i18n/zh/jo.json | 3 + src/i18n/zh/productionProcess.json | 5 + 17 files changed, 521 insertions(+), 46 deletions(-) create mode 100644 src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts index 2ffe85d..fedea25 100644 --- a/src/app/api/itemTracing/index.ts +++ b/src/app/api/itemTracing/index.ts @@ -138,6 +138,7 @@ export interface ItemLotTraceMovement { relationshipId?: number | null; doOutboundIsExtra?: boolean; doOutboundIsReplenish?: boolean; + doOutboundQtyChanged?: boolean; processingStatus?: string; matchStatus?: string; } @@ -199,7 +200,7 @@ export interface ItemLotTraceStockTakeEvent { stockTakeRoundName?: string; varianceQty: number; beforeQty: number; - afterQty: number; + afterQty?: number | null; approver: string; timestamp: string | null; recordDetail?: ItemLotTraceStockTakeRecordDetail | null; @@ -409,6 +410,8 @@ export interface ItemLotTraceDoDelivery { doPickOrderRecordId?: number | null; isExtra?: boolean; isReplenish?: boolean; + /** True when pick_order_line.qty ≠ SUM(stock_out_line.qty) for that line (改數). */ + qtyChanged?: boolean; } export interface ItemLotTraceReturnEvent { diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index cd58bae..02ca263 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1643,6 +1643,36 @@ export const fetchOperatorKpi = cache(async (date?: string) => { }); }); +// ===== Drink Production Qty Dashboard ===== +export interface DrinkProductionQtyJobOrderDetail { + jobOrderId: number; + jobOrderCode?: string | null; + productionDate?: string | null; + reqQty: number; + productionQty: number; +} + +export interface DrinkProductionQtyResponse { + itemCode?: string | null; + itemName?: string | null; + uom?: string | null; + totalReqQty: number; + totalQty: number; + jobOrders?: DrinkProductionQtyJobOrderDetail[]; +} + +export const fetchDrinkProductionQty = cache(async (date?: string) => { + const params = new URLSearchParams(); + if (date) params.set("date", date); + const qs = params.toString(); + const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`; + + return serverFetchJson(url, { + method: "GET", + next: { tags: ["drinkProductionQty"] }, + }); +}); + // ===== Equipment Status Dashboard ===== export interface EquipmentStatusProcessInfo { diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx index 7d8e93a..bd9d6e9 100644 --- a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx +++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx @@ -91,31 +91,22 @@ const ItemTracingFlowGraphSearch: React.FC = ({ }} sx={{ "& .MuiOutlinedInput-root": { - justifyContent: hasQuery ? "flex-start" : "center", + alignItems: "center", }, "& .MuiOutlinedInput-input": { - textAlign: hasQuery ? "left" : "center", color: "text.secondary", - ...(hasQuery - ? {} - : { - flex: "0 1 auto", - width: "auto", - maxWidth: "90%", - }), + py: "8.5px", + lineHeight: 1.4375, }, "& .MuiOutlinedInput-input::placeholder": { - color: "text.secondary", + color: "text.disabled", opacity: 1, - textAlign: "center", }, - ...(!hasQuery - ? { - "& .MuiInputAdornment-positionStart": { - marginRight: 0.75, - }, - } - : {}), + "& .MuiInputAdornment-root": { + height: "100%", + maxHeight: "none", + alignItems: "center", + }, }} /> {hasQuery && ( diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx index fe2b9d8..b820638 100644 --- a/src/components/ItemTracing/ItemTracingSections.tsx +++ b/src/components/ItemTracing/ItemTracingSections.tsx @@ -459,11 +459,6 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) align: "right", value: (row) => formatQty(row.qty, stockUom), }, - { - key: "ref", - label: t("ref"), - value: (row) => row.transferCode, - }, { key: "timestamp", label: t("timestamp"), @@ -485,8 +480,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "material", label: t("material"), value: (m) => m.materialItemCode }, - { key: "materialLot", label: t("materialLot"), value: (m) => m.materialLotNo }, + { key: "material", label: t("itemCode"), value: (m) => m.materialItemCode }, + { key: "materialLot", label: t("itemLot"), value: (m) => m.materialLotNo }, { key: "pickOrder", label: t("pickOrder"), @@ -579,8 +574,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "material", label: t("material"), value: (u) => u.materialItemCode }, - { key: "materialLot", label: t("materialLot"), value: (u) => u.materialLotNo }, + { key: "material", label: t("itemCode"), value: (u) => u.materialItemCode }, + { key: "materialLot", label: t("itemLot"), value: (u) => u.materialLotNo }, { key: "materialQty", label: t("materialQty"), @@ -610,8 +605,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "finishedItem", label: t("finishedItem"), value: (d) => d.finishedItemCode }, - { key: "finishedLot", label: t("finishedLot"), value: (d) => d.finishedLotNo }, + { key: "finishedItem", label: t("itemCode"), value: (d) => d.finishedItemCode }, + { key: "finishedLot", label: t("itemLot"), value: (d) => d.finishedLotNo }, { key: "fgQty", label: t("fgQty"), @@ -627,10 +622,10 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ], [t, stockUom]); const bomRecipeCols = useMemo((): FilterableColumnDef<(typeof bomTrace.bomRecipe)[number]>[] => [ - { key: "material", label: t("material"), value: (r) => r.materialItemCode }, + { key: "material", label: t("itemCode"), value: (r) => r.materialItemCode }, { key: "materialName", - label: `${t("material")} name`, + label: t("detailItemName"), value: (r) => r.materialItemName, }, { @@ -664,7 +659,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) "—" ), }, - { key: "material", label: t("material"), value: (row) => row.materialItemCode }, + { key: "material", label: t("Item"), value: (row) => row.materialItemCode }, { key: "lotNo", label: t("lotNo"), @@ -703,7 +698,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) const joPickLineCols = useMemo((): FilterableColumnDef[] => [ { key: "material", - label: t("material"), + label: t("Item"), value: (line) => line.itemName ? `${line.itemCode} · ${line.itemName}` : line.itemCode, }, @@ -719,8 +714,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) align: "right", value: (line) => formatQty(Number(line.pickedQty), stockUom), }, - { key: "status", label: t("status"), value: (line) => line.status }, - ], [t, stockUom]); + { key: "status", label: t("status"), value: (line) => tr.pickStatus(line.status) }, + ], [t, tr, stockUom]); return ( @@ -878,7 +873,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) {" · "} diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx index 6a3be79..43d32dc 100644 --- a/src/components/ItemTracing/TraceFlowNodes.tsx +++ b/src/components/ItemTracing/TraceFlowNodes.tsx @@ -31,7 +31,7 @@ import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout"; import { kindColor, kindLabelKey } from "./traceFlowNodeUtils"; import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; -import { pickStatusValueColor } from "./traceLabelUtils"; +import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils"; const PHASE_LABEL_INNER = 96; /** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */ @@ -348,7 +348,7 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ {t("Item")}: {node.meta?.trim() || node.traceItemCode} ) : null} - {node.qty != null && ( + {(node.qty != null || node.kind === "STOCK_TAKE") && ( {node.kind === "PURCHASE" @@ -385,6 +394,31 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ : formatQty(node.qty, node.uom)} )} + {node.kind === "STOCK_TAKE" ? ( + 0 + ? "success.main" + : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0 + ? "error.main" + : undefined, + }} + > + {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)} + + ) : null} {node.traceLotNo ? ( (); @@ -660,6 +665,7 @@ export const buildTraceGraphNodes = ( doMetaByPickCode.set(code, { isExtra: chipFlags.isExtra, isReplenish: chipFlags.isReplenish, + qtyChanged: d.qtyChanged === true, consoCode: d.consoCode?.trim() || code, }); }); @@ -884,6 +890,9 @@ export const buildTraceGraphNodes = ( : undefined, doOutboundIsExtra: chipFlags?.isExtra, doOutboundIsReplenish: chipFlags?.isReplenish, + doOutboundQtyChanged: isDoOut + ? (pickMeta?.qtyChanged ?? m.doOutboundQtyChanged === true) + : undefined, warehouseCode: m.warehouseCode?.trim() || undefined, categoryLabel: categoryForKind(kind, labels), ...(isJoOut @@ -1022,10 +1031,12 @@ export const buildTraceGraphNodes = ( 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(" · "); + detail?.varianceQty != null + ? Number(detail.varianceQty) + : acceptedQty != null && e.varianceQty != null + ? Number(e.varianceQty) + : null; + const meta = [e.approver, roundLabel, e.stockTakeSection].filter(Boolean).join(" · "); nodes.push({ id: lb ? `${idPfx}st-${i}-${e.stockTakeCode}` @@ -1043,6 +1054,7 @@ export const buildTraceGraphNodes = ( traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined, categoryLabel: labels.categoryStockTake, stockTakeRecordDetail: detail, + stockTakeVarianceQty: varianceQty, details: [ field(labels.detailStockTakeCode, e.stockTakeCode), fieldIf(labels.detailStockTakeRound, roundLabel || undefined), diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts index 577d5ea..5aae4a4 100644 --- a/src/components/ItemTracing/traceLabelUtils.ts +++ b/src/components/ItemTracing/traceLabelUtils.ts @@ -176,3 +176,16 @@ export const resolveDoOutboundChipFlags = ( isExtra: resolveTraceDoOutboundIsExtra(source), isReplenish: source.isReplenish === true, }); + +/** + * Outbound qty color (DO_OUT / JO_OUT / MATERIAL_PICK): + * 改數 → orange; else qty > 0 → green; qty ≤ 0 → red. + */ +export const resolveDoOutboundQtyColor = ( + qty: number | null | undefined, + qtyChanged?: boolean, +): "warning.main" | "success.main" | "error.main" | undefined => { + if (qtyChanged) return "warning.main"; + if (qty == null || !Number.isFinite(Number(qty))) return undefined; + return Number(qty) > 0 ? "success.main" : "error.main"; +}; diff --git a/src/components/ItemTracing/traceStockTakeUtils.ts b/src/components/ItemTracing/traceStockTakeUtils.ts index ef2cc1f..d192736 100644 --- a/src/components/ItemTracing/traceStockTakeUtils.ts +++ b/src/components/ItemTracing/traceStockTakeUtils.ts @@ -34,14 +34,16 @@ export const resolveStockTakeBookQty = ( /** * Accepted physical qty used for variance / posting decision. * Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty. + * Incomplete rounds (no count yet) must not surface as 0 via COALESCE(finalQty, 0). */ export const resolveStockTakeAcceptedQty = ( detail: ItemLotTraceStockTakeRecordDetail | null | undefined, fallbackAfterQty?: number | null, ): number | null => { + const pick = (n: number | null | undefined) => + n != null && !Number.isNaN(Number(n)) ? Number(n) : null; + if (detail) { - 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); @@ -67,6 +69,13 @@ export const resolveStockTakeAcceptedQty = ( if (fromSecond != null) return fromSecond; const fromFirst = pick(detail.pickerFirstQty); if (fromFirst != null) return fromFirst; + + const status = (detail.recordStatus ?? "").trim().toUpperCase(); + const accepted = + status === "ACCEPTED" || status === "COMPLETED" || status === "COMPLETE"; + if (!accepted) { + return null; + } } if (fallbackAfterQty != null && !Number.isNaN(Number(fallbackAfterQty))) { return Number(fallbackAfterQty); diff --git a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx new file mode 100644 index 0000000..9dd30d3 --- /dev/null +++ b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx @@ -0,0 +1,349 @@ +"use client"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { + Box, + Typography, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + CircularProgress, + Stack, + IconButton, + Collapse, + Link as MuiLink, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import NextLink from "next/link"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import type { Dayjs } from "dayjs"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { + fetchDrinkProductionQty, + DrinkProductionQtyResponse, +} from "@/app/api/jo/actions"; + +const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 + +const formatQty = (qty: number | null | undefined): string => { + if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; + return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); +}; + +const formatProductionDate = (value: string | null | undefined): string => { + if (!value) return "-"; + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; +}; + +const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => + `${row.itemCode || "unknown"}-${idx}`; + +const DrinkProductionQtyDashboard: React.FC = () => { + const { t } = useTranslation(["common", "jo", "productionProcess"]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedDate, setSelectedDate] = useState(dayjs()); + const [expandedRowKeys, setExpandedRowKeys] = useState>( + new Set(), + ); + const refreshCountRef = useRef(0); + const [lastDataRefreshTime, setLastDataRefreshTime] = useState( + null, + ); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const result = await fetchDrinkProductionQty( + selectedDate.format("YYYY-MM-DD"), + ); + setData(result || []); + setExpandedRowKeys(new Set()); + setLastDataRefreshTime(dayjs()); + refreshCountRef.current += 1; + } catch (error) { + console.error("Error fetching drink production qty:", error); + setData([]); + setExpandedRowKeys(new Set()); + } finally { + setLoading(false); + } + }, [selectedDate]); + + useEffect(() => { + loadData(); + const interval = setInterval(() => { + loadData(); + }, REFRESH_INTERVAL); + return () => clearInterval(interval); + }, [loadData]); + + const toggleRowExpanded = (rowKey: string) => { + setExpandedRowKeys((prev) => { + const next = new Set(prev); + if (next.has(rowKey)) { + next.delete(rowKey); + } else { + next.add(rowKey); + } + return next; + }); + }; + + return ( + + + + {t("Drink Production Qty Dashboard")} + + + + + { + if (newValue) setSelectedDate(newValue); + }} + format="YYYY-MM-DD" + slotProps={{ + textField: { size: "small", sx: { minWidth: 160 } }, + }} + /> + + + + + + {t("Auto-refresh every 10 minutes")} |  + {t("Last updated")}:{" "} + {lastDataRefreshTime + ? lastDataRefreshTime.format("HH:mm:ss") + : "--:--:--"} + + + + {loading ? ( + + + + ) : ( + + + + + + + + {t("Item Code")} + + + + + {t("Goods Name")} + + + + + {t("Unit")} + + + + + {t("Stock Req. Qty")} + + + + + {t("Production Qty")} + + + + + + {data.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( + data.map((row, idx) => { + const rowKey = getRowKey(row, idx); + const jobOrders = row.jobOrders ?? []; + const isExpanded = expandedRowKeys.has(rowKey); + const hasJobOrders = jobOrders.length > 0; + + return ( + + + + {hasJobOrders ? ( + toggleRowExpanded(rowKey)} + > + {isExpanded ? ( + + ) : ( + + )} + + ) : null} + + + + {row.itemCode || "-"} + + + + + {row.itemName || "-"} + + + + + {row.uom || "-"} + + + + + {formatQty(row.totalReqQty)} + + + + + {formatQty(row.totalQty)} + + + + {hasJobOrders && ( + + + + +
+ + + + {t("Job Order Code")} + + + {t("Production Date")} + + + {t("Stock Req. Qty")} + + + {t("Production Qty")} + + + + + {jobOrders.map((jo) => ( + + + {jo.jobOrderId > 0 ? ( + + {jo.jobOrderCode || + `JO-${jo.jobOrderId}`} + + ) : ( + jo.jobOrderCode || "-" + )} + + + {formatProductionDate( + jo.productionDate, + )} + + + {formatQty(jo.reqQty)} + + + {formatQty(jo.productionQty)} + + + ))} + +
+ + + + + )} + + ); + }) + )} + + +
+ )} +
+
+ ); +}; + +export default DrinkProductionQtyDashboard; diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index f78c95e..09e4772 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -14,6 +14,7 @@ import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionse import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; +import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; import type { PrinterCombo } from "@/app/api/settings/printer"; import { useTranslation } from "react-i18next"; @@ -208,6 +209,7 @@ const ProductionProcessPage: React.FC = ({ printerCo + {tabIndex === 0 && ( @@ -256,7 +258,8 @@ const ProductionProcessPage: React.FC = ({ printerCo }} /> )} - {tabIndex === 2 && ( + + {tabIndex === 2 && ( = ({ printerCo {tabIndex === 5 && ( )} + {tabIndex === 6 && ( + + )} Date: Mon, 20 Jul 2026 19:04:21 +0800 Subject: [PATCH 11/16] no message --- src/app/api/jo/actions.ts | 1 + src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx | 2 +- src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx | 2 +- src/components/ItemTracing/ItemTracingSections.tsx | 2 +- src/components/ItemTracing/TraceFlowNodes.tsx | 1 + src/components/ItemTracing/buildTraceGraphNodes.ts | 1 + src/components/ItemTracing/exportItemLotTraceXlsx.ts | 2 +- src/components/ItemTracing/traceLabelUtils.ts | 1 + src/components/ItemTracing/traceStockTakeUtils.ts | 1 + .../ProductionProcess/DrinkProductionQtyDashboard.tsx | 1 + src/components/ProductionProcess/ProductionProcessPage.tsx | 1 + 11 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 02ca263..8d67ac1 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1661,6 +1661,7 @@ export interface DrinkProductionQtyResponse { jobOrders?: DrinkProductionQtyJobOrderDetail[]; } +/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ export const fetchDrinkProductionQty = cache(async (date?: string) => { const params = new URLSearchParams(); if (date) params.set("date", date); diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx index bd9d6e9..58fbbe0 100644 --- a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx +++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx @@ -25,7 +25,7 @@ type Props = { onClear: () => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ const ItemTracingFlowGraphSearch: React.FC = ({ query, matchCount, diff --git a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx index 6fb9422..ed1978e 100644 --- a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx +++ b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx @@ -30,7 +30,7 @@ type Props = { onClose?: () => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ const ItemTracingNodeDetailPanel: React.FC = ({ node, onClose }) => { const { t } = useTranslation("itemTracing"); diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx index b820638..a174648 100644 --- a/src/components/ItemTracing/ItemTracingSections.tsx +++ b/src/components/ItemTracing/ItemTracingSections.tsx @@ -46,7 +46,7 @@ type Props = { onTrace?: (params: TraceParams) => void; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-17 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.1 | 2026-07-20 */ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) => { const { t } = useTranslation("itemTracing"); const [tab, setTab] = useState(0); diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx index 43d32dc..8a116c7 100644 --- a/src/components/ItemTracing/TraceFlowNodes.tsx +++ b/src/components/ItemTracing/TraceFlowNodes.tsx @@ -73,6 +73,7 @@ const GroupCollapseButton = ({ ); +/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ id, data, diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts index 0ad7a17..62ad86a 100644 --- a/src/components/ItemTracing/buildTraceGraphNodes.ts +++ b/src/components/ItemTracing/buildTraceGraphNodes.ts @@ -564,6 +564,7 @@ const buildInboundOriginNodes = ( return { nodes, nextSeq: seq }; }; +/** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */ export const buildTraceGraphNodes = ( data: ItemLotTraceResponse, labels: TraceGraphDetailLabels, diff --git a/src/components/ItemTracing/exportItemLotTraceXlsx.ts b/src/components/ItemTracing/exportItemLotTraceXlsx.ts index d8776f3..01c2c7e 100644 --- a/src/components/ItemTracing/exportItemLotTraceXlsx.ts +++ b/src/components/ItemTracing/exportItemLotTraceXlsx.ts @@ -889,7 +889,7 @@ export const buildItemLotTraceSheets = ( export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); -/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.0 | 2026-07-17 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ export const exportItemLotTraceXlsx = ( data: ItemLotTraceResponse, compiledGraph: CompiledTraceGraph, diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts index 5aae4a4..af0408c 100644 --- a/src/components/ItemTracing/traceLabelUtils.ts +++ b/src/components/ItemTracing/traceLabelUtils.ts @@ -178,6 +178,7 @@ export const resolveDoOutboundChipFlags = ( }); /** + * FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 * Outbound qty color (DO_OUT / JO_OUT / MATERIAL_PICK): * 改數 → orange; else qty > 0 → green; qty ≤ 0 → red. */ diff --git a/src/components/ItemTracing/traceStockTakeUtils.ts b/src/components/ItemTracing/traceStockTakeUtils.ts index d192736..730802f 100644 --- a/src/components/ItemTracing/traceStockTakeUtils.ts +++ b/src/components/ItemTracing/traceStockTakeUtils.ts @@ -32,6 +32,7 @@ export const resolveStockTakeBookQty = ( }; /** + * FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 * Accepted physical qty used for variance / posting decision. * Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty. * Incomplete rounds (no count yet) must not surface as 0 via COALESCE(finalQty, 0). diff --git a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx index 9dd30d3..94626e9 100644 --- a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx +++ b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx @@ -47,6 +47,7 @@ const formatProductionDate = (value: string | null | undefined): string => { const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => `${row.itemCode || "unknown"}-${idx}`; +/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ const DrinkProductionQtyDashboard: React.FC = () => { const { t } = useTranslation(["common", "jo", "productionProcess"]); const [data, setData] = useState([]); diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index 09e4772..d060c7b 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -24,6 +24,7 @@ interface ProductionProcessPageProps { const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; +/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ const ProductionProcessPage: React.FC = ({ printerCombo }) => { const { t } = useTranslation(["common"]); const [selectedProcessId, setSelectedProcessId] = useState(null); From ff6789711c1a9e1da8a1ee9a28980298b1f13855 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 22 Jul 2026 14:06:45 +0800 Subject: [PATCH 12/16] =?UTF-8?q?=E5=BB=BA=E8=AD=B0=E6=89=B9=E8=99=9F=20UO?= =?UTF-8?q?M=20=E6=AA=A2=E6=A0=B8=EF=BC=8F=E4=B8=8D=E7=AC=A6=E5=89=87?= =?UTF-8?q?=E6=93=8B=E3=80=81=E6=A8=99=E7=B1=A4=E5=88=97=E5=8D=B0=E5=8F=AA?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=90=8C=20UOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/doworkbench/actions.ts | 13 +- src/app/api/inventory/actions.ts | 6 +- src/app/api/settings/item/actions.ts | 24 ++ .../WorkbenchGoodPickExecutionDetail.tsx | 114 +++++++- .../WorkbenchLotLabelPrintModal.tsx | 79 +++-- .../InventorySearch/InventorySearch.tsx | 1 + .../PickOrderSearch/AssignAndRelease.tsx | 11 +- .../PickOrderSearch/CreatedItemsTable.tsx | 25 +- .../PickOrderSearch/SearchResultsTable.tsx | 125 ++++---- .../WorkbenchPickExecution.tsx | 170 ++++++++++- .../PickOrderSearch/newcreatitem.tsx | 276 +++++++++--------- src/i18n/en/pickOrder.json | 4 + src/i18n/zh/pickOrder.json | 4 + 13 files changed, 581 insertions(+), 271 deletions(-) diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index b9353cd..6eb140e 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -417,6 +417,8 @@ export async function fetchWorkbenchCompletedLotDetails( export type WorkbenchScanPayload = { itemId: number; stockInLineId: number; + /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */ + uomId?: number | null; }; export async function fetchWorkbenchPrinters() { return serverFetchJson(`${BASE_API_URL}/printers`, { @@ -433,9 +435,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) { }); } -export async function fetchWorkbenchAvailableLotsByItem(itemId: number) { +export async function fetchWorkbenchAvailableLotsByItem( + itemId: number, + uomId?: number | null, +) { + const qs = + uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0 + ? `?uomId=${Number(uomId)}` + : ""; return serverFetchJson( - `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`, + `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`, { method: "GET", cache: "no-store", diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 417c1b3..5da0eeb 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -23,6 +23,7 @@ export interface LotLineInfo { lotNo: string; remainingQty: number; uom: string; + uomId: number; } export interface SearchInventoryLotLine extends Pageable { @@ -170,7 +171,10 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { export const fetchInventories = cache(fetchInventoriesImpl); -/** Inventory search page: latest inventory row per item (no baseUnit/uomId filter). */ +/** + * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 + * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). + */ export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); /** Bypass React cache() after mutations so lists show fresh qty. */ diff --git a/src/app/api/settings/item/actions.ts b/src/app/api/settings/item/actions.ts index 3f4b782..f6b9180 100644 --- a/src/app/api/settings/item/actions.ts +++ b/src/app/api/settings/item/actions.ts @@ -95,6 +95,30 @@ export interface ItemWithDetails { uomDesc: string; currentStockBalance: number; } + +export interface PickUomOption { + uomId: number; + uomCode: string; + uomDesc: string; +} + +export interface AvailablePickUomsResponse { + itemId: number; + stockUom: PickUomOption | null; + options: PickUomOption[]; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */ +export const fetchAvailablePickUoms = cache(async (itemId: number) => { + return serverFetchJson( + `${BASE_API_URL}/items/${itemId}/available-pick-uoms`, + { + method: "GET", + next: { tags: ["items"] }, + }, + ); +}); + export const fetchItemsWithDetails = cache(async (searchParams?: Record) => { if (searchParams) { const queryString = new URLSearchParams(searchParams).toString(); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 146bc59..84af6b6 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record) } } +/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */ const WorkbenchGoodPickExecutionDetail: React.FC = ({ filterArgs, onSwitchToRecordTab, @@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemId: line.item.id, itemCode: line.item.code, itemName: line.item.name, + uomId: line.item.uomId ?? null, uomDesc: line.item.uomDesc, uomShortDesc: line.item.uomShortDesc, @@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemId: line.item.id, itemCode: line.item.code, itemName: line.item.name, + uomId: line.item.uomId ?? null, uomDesc: line.item.uomDesc, uomShortDesc: line.item.uomShortDesc, @@ -1688,14 +1691,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }); return; } - + // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) const lookupStartTime = performance.now(); - const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; + let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected - const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; + let allLotsForItem = indexes.byItemId.get(scannedItemId) || []; const lookupTime = performance.now() - lookupStartTime; console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); + + // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs + // (same item may have multiple lines with different UOMs on one pick order) + let scannedUomId = 0; + const allowedUomIds = new Set( + allLotsForItem + .map((l: any) => Number(l?.uomId)) + .filter((id: number) => Number.isFinite(id) && id > 0), + ); + const hasLineUoms = allowedUomIds.size > 0; + try { + const lotDetail = await fetchLotDetail(scannedStockInLineId); + scannedUomId = Number(lotDetail?.uomId) || 0; + } catch (e) { + if (hasLineUoms) { + console.warn( + "[QR PROCESS] lot-detail UOM check failed; rejecting scan", + e, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + } + if ( + hasLineUoms && + (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) + ) { + console.warn( + ` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + + // Strict UOM filter — never fall back to other UOM lines + if (scannedUomId > 0 && hasLineUoms) { + allLotsForItem = allLotsForItem.filter( + (l: any) => Number(l?.uomId) === scannedUomId, + ); + activeSuggestedLots = activeSuggestedLots.filter( + (l: any) => Number(l?.uomId) === scannedUomId, + ); + if (allLotsForItem.length === 0) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + } // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots // This allows users to scan other lots even when all suggested lots are rejected @@ -2646,7 +2726,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const qrDetectionStartTime = performance.now(); console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); - console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); + //console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); const qrPayload = parseWorkbenchScanQrPayload(latestQr); @@ -3457,7 +3537,7 @@ const handleStartScan = useCallback(() => { resetScan(); lastConsumedQrValuesLengthRef.current = 0; }, [stopScan, resetScan]); - // ... existing code around line 1469 ... + const handlelotnull = useCallback(async (lot: any) => { // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId const stockOutLineId = lot.stockOutLineId; @@ -4106,7 +4186,7 @@ paginatedData.map((row, index) => { ? Number(fromPickRow) : lockedSubmitQtyDisplay; - // 检查是否是 issue lot + const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); @@ -4131,14 +4211,20 @@ paginatedData.map((row, index) => { }} > - + {row.isGroupFirst ? row.groupDisplayIndex : ""} - {row.isGroupFirst ? lot.itemCode : ""} + + {row.isGroupFirst ? lot.itemCode : ""} + + + + {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} + @@ -4146,7 +4232,7 @@ paginatedData.map((row, index) => { - + {(() => { const hasLotNo = Boolean(lot.lotNo); @@ -4165,7 +4251,7 @@ paginatedData.map((row, index) => { : 'inherit'; return ( { sx={{ flexShrink: 0, fontSize: "0.75rem", - py: 0.25, + //py: 0.25, minWidth: "auto", px: 1, whiteSpace: "nowrap", @@ -4556,6 +4642,12 @@ paginatedData.map((row, index) => { ).trim() || null : null } + expectedUomId={ + workbenchLotLabelContextLot != null && + Number(workbenchLotLabelContextLot.uomId) > 0 + ? Number(workbenchLotLabelContextLot.uomId) + : null + } disableScanPick={workbenchLotLabelScanPickDisabled} onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} submitQty={workbenchLotLabelSubmitQty} diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index e6abd6d..2863a5b 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = { inventoryLotLineId: number; warehouseCode?: string | null; warehouseName?: string | null; + uom?: string | null; + uomId?: number | null; } | null; sameItemLots: Array<{ lotNo: string; @@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = { stockInLineId?: number | null; availableQty: number; uom: string; + uomId?: number | null; warehouseCode?: string | null; warehouseName?: string | null; }>; @@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps { /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ triggerLotAvailableQty?: number | null; triggerLotUom?: string | null; + /** POL UomConversion id — workbench lot list only returns matching UOM */ + expectedUomId?: number | null; /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ disableScanPick?: boolean; /** @@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean { return s.includes("label") && !s.includes("a4"); } +/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, @@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC = hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, + expectedUomId = null, disableScanPick = false, onWorkbenchScanPick, submitQty = null, @@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC = if (id != null) setSelectedPrinterId(id); }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); + const resolveExpectedUomId = useCallback((): number | null => { + const n = Number(expectedUomId); + return Number.isFinite(n) && n > 0 ? n : null; + }, [expectedUomId]); + const analyzePayload = useCallback( async (payload: ScanPayload) => { setLastPayload(payload); setScanError(null); setAnalysisLoading(true); try { - const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; + const uomId = resolveExpectedUomId(); + const data = (await analyzeWorkbenchQrCode({ + ...payload, + ...(uomId != null ? { uomId } : {}), + })) as QrCodeAnalysisResponse; setAnalysis(data); setSnackbar({ open: true, @@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysisLoading(false); } }, - [], + [resolveExpectedUomId], ); const analyzeByItem = useCallback( @@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC = setScanError(null); setAnalysisLoading(true); try { - const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { + const uomId = resolveExpectedUomId(); + const data = (await fetchWorkbenchAvailableLotsByItem( + itemId, + uomId, + )) as { itemId: number; itemCode: string; itemName: string; @@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysisLoading(false); } }, - [], + [resolveExpectedUomId], ); const handleAnalyze = useCallback(async () => { @@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC = Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; const fromApi = Number(scannedRow?.availableQty ?? 0); const scanned = analysis.scanned; - const scannedLot = scannedLotLineId - ? { - lotNo: scanned?.lotNo ?? "", - inventoryLotLineId: scannedLotLineId, - stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, - availableQty: Math.max(fromApi, fromTable) as number, - uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, - warehouseCode: - scanned?.warehouseCode ?? scannedRow?.warehouseCode, - warehouseName: - scanned?.warehouseName ?? scannedRow?.warehouseName, - _scanned: true as const, - } - : null; + const expectUom = Number(expectedUomId); + const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0; + const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0); + const scannedUomOk = + !hasExpectUom || + !Number.isFinite(scannedUomId) || + scannedUomId <= 0 || + scannedUomId === expectUom; + const scannedLot = + scannedLotLineId && scannedUomOk + ? { + lotNo: scanned?.lotNo ?? "", + inventoryLotLineId: scannedLotLineId, + stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, + availableQty: Math.max(fromApi, fromTable) as number, + uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string, + uomId: scannedUomId > 0 ? scannedUomId : null, + warehouseCode: + scanned?.warehouseCode ?? scannedRow?.warehouseCode, + warehouseName: + scanned?.warehouseName ?? scannedRow?.warehouseName, + _scanned: true as const, + } + : null; const merged = [ ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), ...list .filter((x) => x.inventoryLotLineId !== scannedLotLineId) + .filter((x) => { + if (!hasExpectUom) return true; + const id = Number(x.uomId); + return !Number.isFinite(id) || id <= 0 || id === expectUom; + }) .map((x) => ({ ...x, _scanned: false as const })), ]; return merged; - }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); + }, [ + analysis, + hideTriggeredLot, + triggerLotAvailableQty, + triggerLotUom, + expectedUomId, + ]); const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 8909955..b1c2dd5 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -56,6 +56,7 @@ type SearchQuery = Partial< >; type SearchParamNames = keyof SearchQuery; +/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const { t } = useTranslation(['inventory', 'common', 'item']); diff --git a/src/components/PickOrderSearch/AssignAndRelease.tsx b/src/components/PickOrderSearch/AssignAndRelease.tsx index b34b49a..7abe80e 100644 --- a/src/components/PickOrderSearch/AssignAndRelease.tsx +++ b/src/components/PickOrderSearch/AssignAndRelease.tsx @@ -50,7 +50,9 @@ interface ItemRow { id: string; pickOrderId: number; pickOrderCode: string; + pickOrderLineId?: number; itemId: number; + uomId?: number; itemCode: string; itemName: string; requiredQty: number; @@ -85,6 +87,7 @@ const style = { width: { xs: "100%", sm: "100%", md: "100%" }, }; +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const AssignAndRelease: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { setIsUploading } = useUploadContext(); @@ -167,11 +170,13 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { // const filteredRecords = res.records.filter( // (item: any) => (item.status || "").toLowerCase() === "pending" //); - const itemRows: ItemRow[] = res.records.map((item: any) => ({ - id: item.id, + const itemRows: ItemRow[] = res.records.map((item: any, index: number) => ({ + id: item.id ?? `${item.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`, pickOrderId: item.pickOrderId, pickOrderCode: item.pickOrderCode, + pickOrderLineId: item.pickOrderLineId, itemId: item.itemId, + uomId: item.uomId, itemCode: item.itemCode, itemName: item.itemName, requiredQty: item.requiredQty, @@ -493,7 +498,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { ) : ( groupedItems.map((group) => ( group.items.map((item, index) => ( - + {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} {index === 0 ? ( diff --git a/src/components/PickOrderSearch/CreatedItemsTable.tsx b/src/components/PickOrderSearch/CreatedItemsTable.tsx index 05d7149..3b2e77b 100644 --- a/src/components/PickOrderSearch/CreatedItemsTable.tsx +++ b/src/components/PickOrderSearch/CreatedItemsTable.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { - Box, Typography, Table, TableBody, @@ -43,15 +42,16 @@ interface Group { interface CreatedItemsTableProps { items: CreatedItem[]; groups: Group[]; - onItemSelect: (itemId: number, checked: boolean) => void; - onQtyChange: (itemId: number, qty: number) => void; - onGroupChange: (itemId: number, groupId: string) => void; + onItemSelect: (itemId: number, checked: boolean, uomId?: number) => void; + onQtyChange: (itemId: number, qty: number, uomId?: number) => void; + onGroupChange: (itemId: number, groupId: string, uomId?: number) => void; pageNum: number; pageSize: number; onPageChange: (event: unknown, newPage: number) => void; onPageSizeChange: (event: React.ChangeEvent) => void; } +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const CreatedItemsTable: React.FC = ({ items, groups, @@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC = ({ }) => { const { t } = useTranslation("pickOrder"); - // Calculate pagination const startIndex = (pageNum - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedItems = items.slice(startIndex, endIndex); - const handleQtyChange = useCallback((itemId: number, value: string) => { + const handleQtyChange = useCallback((itemId: number, uomId: number, value: string) => { const numValue = Number(value); if (!isNaN(numValue) && numValue >= 1) { - onQtyChange(itemId, numValue); + onQtyChange(itemId, numValue, uomId); } }, [onQtyChange]); @@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC = ({ ) : ( paginatedItems.map((item) => ( - + onItemSelect(item.itemId, e.target.checked)} + onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)} /> @@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC = ({ onGroupChange(item.id, e.target.value)} + onChange={(e) => onGroupChange(rowKey, e.target.value)} displayEmpty - disabled={isItemInCreated(item.id)} - > + disabled={inCreated} + > - {t("No Group")} + {t("No Group")} {groups.map((group) => ( - + {group.name} - + ))} - + - - - {/* Current Stock */} + = ({ {item.currentStockBalance?.toLocaleString()||0} - - {/* Stock Unit */} - {item.uomDesc || "-"} + {item.uomDesc || item.uom || "-"} - - {/* Order Quantity */} - - { - const value = e.target.value; - // Only allow numbers - if (value === "" || /^\d+$/.test(value)) { - const numValue = value === "" ? null : Number(value); - onQtyChange(item.id, numValue); - } - }} - onBlur={() => { - // Trigger auto-add check when user finishes input (clicks elsewhere) - onQtyBlur(item.id); // ← Change this to call onQtyBlur instead! - }} - inputProps={{ + handleQtyChange(rowKey, e.target.value)} + onBlur={() => onQtyBlur(rowKey)} + disabled={inCreated} + inputProps={{ + min: 1, + max: item.currentStockBalance || 0, + step: 1, style: { textAlign: 'center' } - }} - sx={{ + }} + sx={{ width: '80px', '& .MuiInputBase-input': { - textAlign: 'center', - cursor: 'text' + textAlign: 'center', + cursor: 'text' } - }} - disabled={isItemInCreated(item.id)} + }} /> - {/* Target Date */} {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} - )) + ); + }) )} @@ -242,4 +227,4 @@ const SearchResultsTable: React.FC = ({ ); }; -export default SearchResultsTable; \ No newline at end of file +export default SearchResultsTable; diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index c0d3d6c..30d95d6 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -35,6 +35,7 @@ import { } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import { fetchLotDetail } from "@/app/api/inventory/actions"; import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; @@ -66,6 +67,7 @@ type LineRow = { requiredQty: number; pickedQty: number; stockUnit: string; + uomId?: number; status: string; lotsRaw: unknown[]; }; @@ -89,6 +91,7 @@ type LotRow = { itemCode: string; itemName: string; uomDesc: string; + uomId?: number; requiredQty: number; pickOrderLineRequiredQty?: number; availableQty: number; @@ -273,6 +276,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] { itemCode: line.itemCode, itemName: line.itemName, uomDesc: toStr(lot.stockUnit) || line.stockUnit, + uomId: line.uomId, requiredQty: toNum(lot.requiredQty, line.requiredQty), pickOrderLineRequiredQty: line.requiredQty, availableQty: toNum(lot.availableQty), @@ -333,6 +337,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { requiredQty: toNum(line?.requiredQty), pickedQty: sumLinePickedQtyFromLots(lots), stockUnit: toStr(item?.uomDesc ?? item?.uomCode), + uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined, status: toStr(line?.status), lotsRaw: lots, }; @@ -351,6 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { }); } +/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -770,6 +776,16 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { processedQrCodesRef.current.add(latestQr); }, []); + /** Soft-fail / switch-PO: allow the same QR to be processed again. */ + const releaseProcessedQr = useCallback((qr: string) => { + const key = String(qr || "").trim(); + if (!key) return; + processedQrCodesRef.current.delete(key); + if (lastProcessedQrRef.current === key) { + lastProcessedQrRef.current = ""; + } + }, []); + const openUnpickableScanLotLabelModal = useCallback( ( pickRow: LotRow, @@ -1177,16 +1193,102 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return; } - const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem; - const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0]; + // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs + // (same item may have multiple lines with different UOMs on one pick order) + let scannedUomId = 0; + const allowedUomIds = new Set( + allLotsForItem + .map((l) => Number(l?.uomId)) + .filter((id) => Number.isFinite(id) && id > 0), + ); + const hasLineUoms = allowedUomIds.size > 0; + try { + const lotDetail = await fetchLotDetail(scannedStockInLineId); + scannedUomId = Number(lotDetail?.uomId) || 0; + } catch (e) { + if (hasLineUoms) { + console.warn( + "[QR PROCESS] lot-detail UOM check failed; rejecting scan", + e, + ); + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + } + if ( + hasLineUoms && + (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) + ) { + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + + // Strict UOM filter — never fall back to other UOM lines + const filterByScannedUom = (rows: LotRow[]) => { + if (!(scannedUomId > 0) || !hasLineUoms) return rows; + return rows.filter((r) => Number(r.uomId) === scannedUomId); + }; + const activeForUom = filterByScannedUom(activeSuggestedLots); + const allForUom = filterByScannedUom(allLotsForItem); + if (hasLineUoms && scannedUomId > 0 && allForUom.length === 0) { + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; + let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; + if (!expectedRow) { + setError(t("Scanned item is not found in current line")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned item is not found in current line")); + }); + releaseProcessedQr(latest); + return; + } const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; - const scannedRowInItem = + let scannedRowInItem = scannedRows.find( (r) => Number(r.itemId) === scannedItemId && - r.stockOutLineId > 0, + r.stockOutLineId > 0 && + (!(scannedUomId > 0) || !hasLineUoms || Number(r.uomId) === scannedUomId), ) || + (!(scannedUomId > 0) || !hasLineUoms + ? scannedRows.find( + (r) => + Number(r.itemId) === scannedItemId && + r.stockOutLineId > 0, + ) + : undefined) || null; if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { @@ -1231,14 +1333,46 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return; } - if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) { - setError(t("Scanned lot is already completed or checked")); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(t("Scanned lot is already completed or checked")); + // Same lot already bound to a completed/checked SOL: only reuse for another + // pending line with the SAME uomId (never cross UOM A ↔ B). + if ( + scannedRowInItem && + (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status)) + ) { + const completedPolId = Number(scannedRowInItem.pickOrderLineId); + const completedSolId = Number(scannedRowInItem.stockOutLineId); + const pendingPoolBase = activeForUom.length > 0 ? activeForUom : allForUom; + const pendingSameUom = pendingPoolBase.filter((r) => { + if (!(r.stockOutLineId > 0)) return false; + if (isCompletedStatus(r.status) || isCheckedStatus(r.status) || isRejectedStatus(r.status)) { + return false; + } + if (Number(r.stockOutLineId) === completedSolId) return false; + if (completedPolId > 0 && Number(r.pickOrderLineId) === completedPolId) return false; + // Require explicit same UOM — do not reuse across different UOM lines + if (scannedUomId > 0 && hasLineUoms) { + return Number(r.uomId) === scannedUomId; + } + // Without line UOM metadata, do not auto-reuse (avoids A/B cross-pick) + return false; }); - return; + const nextExpected = + pickExpectedRowForSubstitution(pendingSameUom) || pendingSameUom[0] || null; + if (nextExpected) { + expectedRow = nextExpected; + // Force substitution path using scanned SIL, not the completed SOL row. + scannedRowInItem = null; + } else { + const msg = t("This lot has already been picked. Please scan another lot."); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } } let scannedState: ConfirmLotState | null = null; @@ -1292,6 +1426,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { pickExpectedRowForSubstitution, lotRowIndexes, openUnpickableScanLotLabelModal, + releaseProcessedQr, resetScan, submitRow, t, @@ -1306,10 +1441,9 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { }, [isScanning, startScan, userId]); useEffect(() => { - if (!selectedPickOrderId) { - lastProcessedQrRef.current = ""; - processedQrCodesRef.current.clear(); - } + // Clear on any PO selection change (including PO1 → PO2), so the same lot QR can be scanned again. + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); }, [selectedPickOrderId]); useEffect(() => { @@ -1779,6 +1913,12 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { statusTitleSeverity={workbenchLotLabelStatusBanner.severity} triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} + expectedUomId={ + workbenchLotLabelContextLot != null && + Number(workbenchLotLabelContextLot.uomId) > 0 + ? Number(workbenchLotLabelContextLot.uomId) + : null + } submitQty={ workbenchLotLabelContextLot?.stockOutLineId ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) diff --git a/src/components/PickOrderSearch/newcreatitem.tsx b/src/components/PickOrderSearch/newcreatitem.tsx index 4cd647e..2f68c7f 100644 --- a/src/components/PickOrderSearch/newcreatitem.tsx +++ b/src/components/PickOrderSearch/newcreatitem.tsx @@ -70,6 +70,8 @@ interface CreatedItem { // Add interface for search items with quantity interface SearchItemWithQty extends ItemCombo { + /** Unique per item+uom search row */ + rowKey: string; qty: number | null; // Changed from number to number | null jobOrderCode?: string; jobOrderId?: number; @@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo { targetDate?: string | null; // Allow null values groupId?: number | null; // Allow null values } + +const toSearchRowKey = (itemId: number, uomId?: number | null) => + `${itemId}_${Number(uomId) > 0 ? Number(uomId) : 0}`; + interface JobOrderDetailPickLine { id: number; code: string; @@ -95,8 +101,9 @@ interface Group { } // Move the counter outside the component to persist across re-renders let checkboxChangeCallCount = 0; -let processingItems = new Set(); +let processingItems = new Set(); +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCreated }) => { const { t } = useTranslation("pickOrder"); const [items, setItems] = useState([]); @@ -174,6 +181,7 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr uom: item.uom, uomId: 0, uomDesc: item.uomDesc || "", // Add missing uomDesc + rowKey: toSearchRowKey(item.id, 0), jobOrderCode: jobOrderDetail.code, jobOrderId: jobOrderDetail.id, currentStockBalance: 0, // Add default value @@ -263,49 +271,50 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr ); }, []); - // Modified handler for search item selection + // Modified handler for search item selection (legacy filteredItems path) const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { if (isSelected) { const item = filteredItems.find(i => i.id === itemId); if (!item) return; - - const existingItem = createdItems.find(created => created.itemId === item.id); - if (existingItem) { + const uomId = Number(item.uomId) || 0; + if (createdItems.some((c) => c.itemId === item.id && Number(c.uomId) === uomId)) { alert(t("Item already exists in created items")); return; } - - // Fix the newCreatedItem creation - add missing uomDesc const newCreatedItem: CreatedItem = { itemId: item.id, itemName: item.label, itemCode: item.label, qty: item.qty || 1, uom: item.uom || "", - uomId: item.uomId || 0, - uomDesc: item.uomDesc || "", // Add missing uomDesc + uomId, + uomDesc: item.uomDesc || "", isSelected: true, currentStockBalance: item.currentStockBalance, - targetDate: item.targetDate || targetDate, // Use item's targetDate or fallback to form's targetDate - groupId: item.groupId || undefined, // Handle null values + targetDate: item.targetDate || targetDate, + groupId: item.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); } }, [filteredItems, createdItems, t, targetDate]); // Handler for created item selection - const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => { + const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => { setCreatedItems(prev => prev.map(item => - item.itemId === itemId ? { ...item, isSelected } : item + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)) + ? { ...item, isSelected } + : item ) ); }, []); - const handleQtyChange = useCallback((itemId: number, newQty: number) => { + const handleQtyChange = useCallback((itemId: number, newQty: number, uomId?: number) => { setCreatedItems(prev => prev.map(item => - item.itemId === itemId + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)) ? { ...item, qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))), @@ -315,18 +324,23 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr ); }, []); - // Check if item is already in created items - const isItemInCreated = useCallback((itemId: number) => { - return createdItems.some(item => item.itemId === itemId); + // Check if item (+ optional uom) is already in created items + const isItemInCreated = useCallback((itemId: number, uomId?: number) => { + return createdItems.some( + (item) => + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)), + ); }, [createdItems]); // 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期 - const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string) => { + const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string, uomId?: number) => { const gid = newGroupId ? Number(newGroupId) : undefined; const group = groups.find(g => g.id === gid); setCreatedItems(prev => prev.map(it => - it.itemId === itemId + it.itemId === itemId && + (uomId == null || Number(it.uomId) === Number(uomId)) ? { ...it, groupId: gid, @@ -410,27 +424,23 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr } }, [t]); - const checkAndAutoAddItem = useCallback((itemId: number) => { - const item = secondSearchResults.find(i => i.id === itemId); + const checkAndAutoAddItem = useCallback((rowKey: string) => { + const item = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (!item) return; - // Check if item has ALL 3 conditions: - // 1. Item is selected (checkbox checked) - const isSelected = selectedSecondSearchItemIds.includes(itemId); - // 2. Group is assigned + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + const isSelected = selectedSecondSearchItemIds.includes(key); const hasGroup = item.groupId !== undefined && item.groupId !== null; - // 3. Quantity is entered const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; - if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) { - // Auto-add to created items + if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id, item.uomId)) { const newCreatedItem: CreatedItem = { itemId: item.id, itemName: item.label, itemCode: item.label, qty: item.qty || 1, uom: item.uom || "", - uomId: item.uomId || 0, + uomId: Number(item.uomId) || 0, uomDesc: item.uomDesc || "", isSelected: true, currentStockBalance: item.currentStockBalance, @@ -438,33 +448,29 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr groupId: item.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - // Remove from search results since it's now in created items - setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); } }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); - // Add this function after checkAndAutoAddItem - // Add this function after checkAndAutoAddItem -const handleQtyBlur = useCallback((itemId: number) => { - // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) + +const handleQtyBlur = useCallback((rowKey: string) => { setTimeout(() => { - const currentItem = secondSearchResults.find(i => i.id === itemId); + const currentItem = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (!currentItem) return; - const isSelected = selectedSecondSearchItemIds.includes(itemId); + const key = currentItem.rowKey || toSearchRowKey(currentItem.id, currentItem.uomId); + const isSelected = selectedSecondSearchItemIds.includes(key); const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; - // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) - if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) { + if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id, currentItem.uomId)) { const newCreatedItem: CreatedItem = { itemId: currentItem.id, itemName: currentItem.label, itemCode: currentItem.label, qty: currentItem.qty || 1, uom: currentItem.uom || "", - uomId: currentItem.uomId || 0, + uomId: Number(currentItem.uomId) || 0, uomDesc: currentItem.uomDesc || "", isSelected: true, currentStockBalance: currentItem.currentStockBalance, @@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => { groupId: currentItem.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); } }, 0); }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); - const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => { + + const handleSearchItemGroupChange = useCallback((rowKey: string, groupId: string) => { const gid = groupId ? Number(groupId) : undefined; const group = groups.find(g => g.id === gid); setSecondSearchResults(prev => prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: gid, @@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => { : item )); - // Check auto-add after group assignment setTimeout(() => { - checkAndAutoAddItem(itemId); + checkAndAutoAddItem(rowKey); }, 0); }, [groups, checkAndAutoAddItem]); // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) @@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => { uomId: item.uomId, uom: item.uom, uomDesc: item.uomDesc, + rowKey: toSearchRowKey(item.id, item.uomId), currentStockBalance: item.currentStockBalance, qty: null, targetDate: targetDate, @@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => { name: "id", label: "", type: "checkbox", - disabled: (item) => isItemInCreated(item.id), // Disable if already in created items + disabled: (item) => isItemInCreated(item.id, item.uomId), // Disable if already in created items }, { @@ -1141,7 +1147,7 @@ const handleQtyBlur = useCallback((itemId: number) => { }, [formProps]); // 添加数量变更处理函数 - const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => { + const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => { const getClampedQty = (qty: number | null, stock?: number) => { if (qty === null) return null; const maxQty = Math.max(0, stock ?? 0); @@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => { setSecondSearchResults(prev => prev.map(item => - item.id === itemId ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } : item + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey + ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } + : item ) ); @@ -1163,24 +1171,24 @@ const handleQtyBlur = useCallback((itemId: number) => { const newIds = ids(selectedSecondSearchItemIds); setSelectedSecondSearchItemIds(newIds); - // 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面 if (newIds.length === secondSearchResults.length) { - // 全选:将所有搜索结果添加到创建项目 secondSearchResults.forEach(item => { - if (!isItemInCreated(item.id)) { + if (!isItemInCreated(item.id, item.uomId)) { handleSearchItemSelect(item.id, true); } }); } else { - // 部分选择:只处理当前页面的选择 secondSearchResults.forEach(item => { - const isSelected = newIds.includes(item.id); - const isCurrentlyInCreated = isItemInCreated(item.id); + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + const isSelected = newIds.includes(key); + const isCurrentlyInCreated = isItemInCreated(item.id, item.uomId); if (isSelected && !isCurrentlyInCreated) { handleSearchItemSelect(item.id, true); } else if (!isSelected && isCurrentlyInCreated) { - setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id)); + setCreatedItems(prev => prev.filter(createdItem => + !(createdItem.itemId === item.id && Number(createdItem.uomId) === Number(item.uomId)) + )); } }); } @@ -1192,13 +1200,19 @@ const handleQtyBlur = useCallback((itemId: number) => { const newlyDeselected = previousIds.filter(id => !ids.includes(id)); newlySelected.forEach(id => { - if (!isItemInCreated(id as number)) { - handleSearchItemSelect(id as number, true); + const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); + if (row && !isItemInCreated(row.id, row.uomId)) { + handleSearchItemSelect(row.id, true); } }); newlyDeselected.forEach(id => { - setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id)); + const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); + if (row) { + setCreatedItems(prev => prev.filter(createdItem => + !(createdItem.itemId === row.id && Number(createdItem.uomId) === Number(row.uomId)) + )); + } }); } }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); @@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => { name: "id", label: "", type: "checkbox", - disabled: (item) => isItemInCreated(item.id), + disabled: (item) => isItemInCreated(item.id, item.uomId), }, { name: "label", @@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => { renderCell: (item) => ( {/* Add right alignment for the value */} - {item.uom || "-"} + {item.uomDesc || item.uom || "-"} ), @@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => { // Only allow numbers if (value === "" || /^\d+$/.test(value)) { const numValue = value === "" ? null : Number(value); - handleSecondSearchQtyChange(item.id, numValue); + handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue); } }} inputProps={{ @@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => { const value = e.target.value; const numValue = value === "" ? null : Number(value); if (numValue !== null && numValue < 1) { - handleSecondSearchQtyChange(item.id, 1); // Enforce min value + handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), 1); // Enforce min value } }} /> @@ -1368,6 +1382,7 @@ const handleQtyBlur = useCallback((itemId: number) => { uomId: item.uomId, uom: item.uom, uomDesc: item.uomDesc, + rowKey: toSearchRowKey(item.id, item.uomId), currentStockBalance: item.currentStockBalance, qty: null, targetDate: undefined, @@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => { }, []); const getValidationMessage = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); const itemsWithoutGroup = selectedItems.filter(item => @@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => { // Fix the handleAddSelectedToCreatedItems function to properly clear selections const handleAddSelectedToCreatedItems = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); - - // Add selected items to created items with their own group info - selectedItems.forEach(item => { - if (!isItemInCreated(item.id)) { - const newCreatedItem: CreatedItem = { - itemId: item.id, - itemName: item.label, - itemCode: item.label, - qty: item.qty || 1, - uom: item.uom || "", - uomId: item.uomId || 0, - uomDesc: item.uomDesc || "", - isSelected: true, - currentStockBalance: item.currentStockBalance, - targetDate: item.targetDate || targetDate, - groupId: item.groupId || undefined, - }; - setCreatedItems(prev => [...prev, newCreatedItem]); - } - }); - - // Clear the selection + + const toAdd: CreatedItem[] = []; + const addedKeys: string[] = []; + for (const item of selectedItems) { + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + if (isItemInCreated(item.id, item.uomId)) continue; + toAdd.push({ + itemId: item.id, + itemName: item.label, + itemCode: item.label, + qty: item.qty || 1, + uom: item.uom || "", + uomId: Number(item.uomId) || 0, + uomDesc: item.uomDesc || "", + isSelected: true, + currentStockBalance: item.currentStockBalance, + targetDate: item.targetDate || targetDate, + groupId: item.groupId || undefined, + }); + addedKeys.push(key); + } + if (toAdd.length > 0) { + setCreatedItems((prev) => [...prev, ...toAdd]); + } setSelectedSecondSearchItemIds([]); - - // Remove the selected/added items from search results entirely - setSecondSearchResults(prev => prev.filter(item => - !selectedSecondSearchItemIds.includes(item.id) - )); + setSecondSearchResults((prev) => + prev.filter((item) => !addedKeys.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))), + ); }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); // Add a validation function to check if selected items are valid const areSelectedItemsValid = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); return selectedItems.every(item => @@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => { // Move these handlers to the component level (outside of CustomSearchResultsTable) // Handle individual checkbox change - ONLY select, don't add to created items - const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { + const handleIndividualCheckboxChange = useCallback((rowKey: string, checked: boolean) => { checkboxChangeCallCount++; if (checked) { // Add to selected IDs - setSelectedSecondSearchItemIds(prev => [...prev, itemId]); + setSelectedSecondSearchItemIds(prev => [...prev, rowKey]); // Set the item's group and targetDate to current group when selected setSecondSearchResults(prev => { const updatedResults = prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: selectedGroup?.id || undefined, @@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => { // Check if should auto-add after state update setTimeout(() => { // Check if we're already processing this item - if (processingItems.has(itemId)) { - //alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`); + if (processingItems.has(rowKey)) { return; } - const updatedItem = updatedResults.find(i => i.id === itemId); + const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (updatedItem) { const isSelected = true; // We just selected it const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; // Only auto-add if item has quantity (scenario 2: enter quantity first, then select) - if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) { - // Mark this item as being processed - processingItems.add(itemId); - + if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id, updatedItem.uomId)) { + processingItems.add(rowKey); + const newCreatedItem: CreatedItem = { itemId: updatedItem.id, itemName: updatedItem.label, itemCode: updatedItem.label, qty: updatedItem.qty || 1, uom: updatedItem.uom || "", - uomId: updatedItem.uomId || 0, + uomId: Number(updatedItem.uomId) || 0, uomDesc: updatedItem.uomDesc || "", isSelected: true, currentStockBalance: updatedItem.currentStockBalance, @@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => { groupId: updatedItem.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId)); - - // Remove from processing set after a short delay + + setSecondSearchResults(current => current.filter(searchItem => + (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== rowKey + )); + setSelectedSecondSearchItemIds(current => current.filter(id => id !== rowKey)); + setTimeout(() => { - processingItems.delete(itemId); + processingItems.delete(rowKey); }, 100); } - - // Show final debug info in one alert - /* - alert(`FINAL DEBUG INFO for item ${itemId}: -Function called ${checkboxChangeCallCount} times -Is Selected: ${isSelected} -Has Group: ${hasGroup} -Has Quantity: ${hasQty} -Quantity: ${updatedItem.qty} -Group ID: ${updatedItem.groupId} -Is Item In Created: ${isItemInCreated(updatedItem.id)} -Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)} -Processing items: ${Array.from(processingItems).join(', ')}`); - */ } }, 0); @@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`); }); } else { // Remove from selected IDs - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== rowKey)); // Clear the item's group and targetDate when deselected setSecondSearchResults(prev => prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: undefined, @@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S if (checked) { // Select all items on current page that are not already in created items const newSelectedIds = paginatedResults - .filter(item => !isItemInCreated(item.id)) - .map(item => item.id); + .filter(item => !isItemInCreated(item.id, item.uomId)) + .map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); setSelectedSecondSearchItemIds(prev => { - const existingIds = prev.filter(id => !paginatedResults.some(item => item.id === id)); + const existingIds = prev.filter(id => !paginatedResults.some(item => + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === id + )); return [...existingIds, ...newSelectedIds]; }); // Set group and targetDate for all selected items on current page setSecondSearchResults(prev => prev.map(item => - newSelectedIds.includes(item.id) + newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ? { ...item, groupId: selectedGroup?.id || undefined, @@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S )); } else { // Deselect all items on current page - const pageItemIds = paginatedResults.map(item => item.id); - setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(id as number))); + const pageItemIds = paginatedResults.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(String(id)))); // Clear group and targetDate for all deselected items on current page setSecondSearchResults(prev => prev.map(item => - pageItemIds.includes(item.id) + pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ? { ...item, groupId: undefined, diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index 5c8cb77..db082be 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -497,6 +497,10 @@ "Tomorrow": "Tomorrow", "packaging": "packaging", "This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.", + "This lot UOM does not match the pick line. Please scan another lot.": "This lot UOM does not match the pick line. Please scan another lot.", + "Scanned lot UOM does not match pick line UOM": "This lot UOM does not match the pick line. Please scan another lot.", + "This lot has already been picked. Please scan another lot.": "This lot has already been picked. Please scan another lot.", + "Scanned lot is already completed or checked": "This lot has already been picked. Please scan another lot.", "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", "Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})", "Day After Tomorrow": "Day After Tomorrow", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 9bfb9b4..0a95cb6 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -507,6 +507,10 @@ "packaging": "提料中", "No Stock Available": "沒有庫存可用", "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", + "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位與提貨行不符,請掃描其他批號。", + "Scanned lot UOM does not match pick line UOM": "此批號單位與提貨行不符,請掃描其他批號。", + "This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。", + "Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。", "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。", "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", "Day After Tomorrow": "後日", From 475857092dcc92dd4293399b68fdd1d38e389944 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 22 Jul 2026 21:45:46 +0800 Subject: [PATCH 13/16] =?UTF-8?q?WorkbenchGoodPickExecutionDetail=20UI=20?= =?UTF-8?q?=E5=A4=A7=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkbenchTicketReleaseTable 加 user filter DO release / FloorLanePanel:Truck X 票 + 搜尋依樓層 --- src/app/api/doworkbench/actions.ts | 10 +- .../DoWorkbench/WorkbenchFloorLanePanel.tsx | 42 ++++- .../WorkbenchGoodPickExecutionDetail.tsx | 163 ++++++++++++------ .../WorkbenchTicketReleaseTable.tsx | 87 +++++++++- .../ReleasedDoPickOrderSelectModal.tsx | 22 ++- .../WorkbenchPickExecution.tsx | 23 ++- src/i18n/en/ticketReleaseTable.json | 3 + src/i18n/zh/pickOrder.json | 33 ++-- src/i18n/zh/ticketReleaseTable.json | 3 + 9 files changed, 304 insertions(+), 82 deletions(-) diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 6eb140e..ad5ce91 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -242,13 +242,16 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection( shopName?: string, storeId?: string, truck?: string, - releaseType?: string + releaseType?: string, + /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */ + floor?: string ): Promise { const params = new URLSearchParams(); if (shopName?.trim()) params.append("shopName", shopName.trim()); if (storeId?.trim()) params.append("storeId", storeId.trim()); if (truck?.trim()) params.append("truck", truck.trim()); if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); + if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); const query = params.toString(); const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; const response = await serverFetchJson(url, { method: "GET" }); @@ -261,7 +264,9 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( storeId?: string, truck?: string, requiredDeliveryDate?: string, - releaseType?: string + releaseType?: string, + /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */ + floor?: string ): Promise { const params = new URLSearchParams(); if (shopName?.trim()) params.append("shopName", shopName.trim()); @@ -269,6 +274,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( if (truck?.trim()) params.append("truck", truck.trim()); if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); + if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); const query = params.toString(); const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; const response = await serverFetchJson(url, { method: "GET" }); diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx index 0c1e3f3..9d74845 100644 --- a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx +++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx @@ -85,7 +85,13 @@ const WorkbenchFloorLanePanel: React.FC = ({ const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState(undefined); const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState(undefined); const [modalInitialShopSearch, setModalInitialShopSearch] = useState(undefined); - const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; + const [modalTruckXFloor, setModalTruckXFloor] = useState(undefined); + const ticketFloorApiKey = useMemo( + () => ticketFloor.replace("/", "").trim().toUpperCase(), + [ticketFloor], + ); + const defaultTruckCount = + (ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0; const etraEnterInFlightRef = useRef(false); const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); @@ -108,16 +114,25 @@ const WorkbenchFloorLanePanel: React.FC = ({ shopName?: string, storeId?: string, truck?: string, - releaseType?: string - ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType), + releaseType?: string, + floor?: string + ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType, floor), loadToday: ( shopName?: string, storeId?: string, truck?: string, requiredDeliveryDate?: string, - releaseType?: string + releaseType?: string, + floor?: string ) => - fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType), + fetchWorkbenchReleasedDoPickOrdersForSelectionToday( + shopName, + storeId, + truck, + requiredDeliveryDate, + releaseType, + floor + ), assignByListItemId: assignByDeliveryOrderPickOrderId, }), [], @@ -234,7 +249,14 @@ const WorkbenchFloorLanePanel: React.FC = ({ pendingRef.current += 1; startFullTimer(); try { - const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X"); + // storeId stays undefined (Truck X); floor splits by DO supplier preferred floor. + const list = await fetchWorkbenchReleasedDoPickOrdersForSelection( + undefined, + undefined, + "車線-X", + undefined, + ticketFloorApiKey + ); setBeforeTodayTruckXCount(list.length); } catch { setBeforeTodayTruckXCount(0); @@ -244,12 +266,13 @@ const WorkbenchFloorLanePanel: React.FC = ({ } }; void loadBeforeTodayTruckX(); - }, [inEtraUi]); + }, [inEtraUi, ticketFloorApiKey]); const clearModalEtraContext = useCallback(() => { setModalReleaseTypeFilter(undefined); setModalFilterRequiredDeliveryDate(undefined); setModalInitialShopSearch(undefined); + setModalTruckXFloor(undefined); }, []); const openEnterEtraView = useCallback(async () => { @@ -538,6 +561,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ setSelectedTruck("車線-X"); setIsDefaultTruck(true); setDefaultDateScope("today"); + setModalTruckXFloor(ticketFloorApiKey); setModalOpen(true); }} > @@ -638,10 +662,11 @@ const WorkbenchFloorLanePanel: React.FC = ({ variant="outlined" onClick={() => { clearModalEtraContext(); - setSelectedStore("4/F"); + setSelectedStore(""); setSelectedTruck("車線-X"); setIsDefaultTruck(true); setDefaultDateScope("before"); + setModalTruckXFloor(ticketFloorApiKey); setModalOpen(true); }} > @@ -746,6 +771,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ releaseTypeFilter={modalReleaseTypeFilter} filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} initialShopSearch={modalInitialShopSearch} + truckXFloor={modalTruckXFloor} onClose={() => { setModalOpen(false); clearModalEtraContext(); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 84af6b6..4b6015d 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -1223,13 +1223,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }, [fetchAllCombinedLotData]); const openWorkbenchLotLabelModalForLot = useCallback( - (lot: any, reminderText?: string | null) => { + ( + lot: any, + reminderText?: string | null, + options?: { markAsScanIssue?: boolean }, + ) => { const itemId = Number(lot?.itemId); const stockInLineId = Number(lot?.stockInLineId); const solId = Number(lot?.stockOutLineId); if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { return; } + // 掃碼 issue 才標記:手動「批號二維碼」不傳 markAsScanIssue,批號不變紅 + if (options?.markAsScanIssue) { + rememberWorkbenchScanReject(solId, reminderText); + } setWorkbenchLotLabelContextLot(lot); if (Number.isFinite(stockInLineId) && stockInLineId > 0) { setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); @@ -1241,7 +1249,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setQrScanSuccess(false); setWorkbenchLotLabelModalOpen(true); }, - [], + [rememberWorkbenchScanReject], ); const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( @@ -1826,6 +1834,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO openWorkbenchLotLabelModalForLot( scannedLot, t("This lot is not available, please scan another lot."), + { markAsScanIssue: true }, ); return; } @@ -1841,6 +1850,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO openWorkbenchLotLabelModalForLot( scannedLot, `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, + { markAsScanIssue: true }, ); return; } @@ -1963,7 +1973,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { + markAsScanIssue: true, + }); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2135,7 +2147,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { + markAsScanIssue: true, + }); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2330,7 +2344,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && exactMatch ) { - openWorkbenchLotLabelModalForLot(exactMatch, failMsg); + openWorkbenchLotLabelModalForLot(exactMatch, failMsg, { + markAsScanIssue: true, + }); return; } if (workbenchMode && exactMatch.stockOutLineId != null) { @@ -2474,7 +2490,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { + markAsScanIssue: true, + }); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -4154,10 +4172,21 @@ const handleSubmitAllScanned = useCallback(async () => { {t("Item Code")} {t("Item Name")} - {t("Route")} - {t("Suggest Lot No.")} - {t("Lot Required Pick Qty")} - {t("Scan Result")} + + {`${t("Route")} / ${t("Suggest Lot No.")}`} + + + {t("Lot Required Pick Qty")} + + + {t("Scan Result")} + {/*{t("Qty will submit")}*/} {t("Submit Required Pick Qty")} @@ -4165,7 +4194,7 @@ const handleSubmitAllScanned = useCallback(async () => { {paginatedData.length === 0 ? ( - + {t("No data available")} @@ -4226,36 +4255,49 @@ paginatedData.map((row, index) => { {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} - - - {lot.routerRoute || '-'} - - - - - + + + + + {lot.routerRoute || "-"} + {(() => { const hasLotNo = Boolean(lot.lotNo); const isExpired = lot.lotAvailability === 'expired'; const isUnavailable = isInventoryLotLineUnavailable(lot); const isNoLotHint = !hasLotNo && !rejectDisplay; // 顯示「請檢查周圍…」那種 - const isIssueText = - Boolean(rejectDisplay) || !hasLotNo || isExpired || isUnavailable; - const textColor = - isNoLotHint - ? 'error.main' // 或 'text.primary':固定黑,不受 handled / unavailable 影響 - : rejectDisplay || isSolRejected || isUnavailable - ? 'error.main' - : isExpired - ? 'warning.main' - : 'inherit'; + const solStatus = String(lot.stockOutLineStatus ?? "").toLowerCase(); + const isComplete = + solStatus === "completed" || + solStatus === "checked" || + solStatus === "partially_completed" || + solStatus === "partially_complete"; + const textColor = + isNoLotHint + ? "error.main" + : rejectDisplay || isSolRejected || isUnavailable + ? "error.main" + : isExpired + ? "warning.main" + : isComplete + ? "success.main" + : "inherit"; + const lotOnly = + hasLotNo && !rejectDisplay && !isExpired && !isUnavailable; return ( {hasLotNo ? ( @@ -4299,7 +4341,6 @@ paginatedData.map((row, index) => { sx={{ flexShrink: 0, fontSize: "0.75rem", - //py: 0.25, minWidth: "auto", px: 1, whiteSpace: "nowrap", @@ -4310,23 +4351,34 @@ paginatedData.map((row, index) => { )} - - {(() => { - const requiredQty = lot.requiredQty || 0; - return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; - })()} + + + + {(() => { + const requiredQty = lot.requiredQty || 0; + return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; + })()} + - + {(() => { const status = lot.stockOutLineStatus?.toLowerCase(); const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; const isNoLot = !lot.lotNo; + const scanResultSlotSx = { + width: 42, + height: 42, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + mx: 'auto', + } as const; // rejected lot:显示红色勾选(已扫描但被拒绝) if (isRejected && !isNoLot) { return ( - + { status !== "partially_completed" && status !== "partially_complete" ) { - return null; + return ; } // 正常 lot:已扫描(checked/partially_completed/completed) if (!isNoLot && status !== 'pending' && status !== 'rejected') { return ( - + { // noLot 且已完成/部分完成:显示红色勾选 if (isNoLot && (status === 'partially_completed' || status === 'completed')) { return ( - + { ); } - return null; + return ; })()} {/* @@ -4477,11 +4529,10 @@ paginatedData.map((row, index) => { {isRowPicked ? ( @@ -4512,7 +4563,16 @@ paginatedData.map((row, index) => { inputProps={{ min: 0, step: 1 }} sx={{ width: 96, - "& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center" }, + "& .MuiInputBase-input": { + typography: "body1", + py: 0.5, + textAlign: "center", + "&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: 0, + }, + MozAppearance: "textfield", + }, }} /> )} @@ -4557,7 +4617,14 @@ paginatedData.map((row, index) => { } - sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }} + sx={{ + fontSize: "0.7rem", + py: 0.5, + minHeight: "28px", + minWidth: "72px", + whiteSpace: "nowrap", + flexShrink: 0, + }} > {t("Just Completed")} diff --git a/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx index 53c824a..60f554b 100644 --- a/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx +++ b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx @@ -5,13 +5,16 @@ import { Box, Card, CardContent, + Checkbox, Chip, CircularProgress, FormControl, InputLabel, + ListItemText, MenuItem, Paper, Select, + SelectChangeEvent, Stack, Table, TableBody, @@ -65,6 +68,14 @@ function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean { ); } +const HANDLER_UNASSIGNED = "__UNASSIGNED__"; +const HANDLER_SELECT_ALL = "__ALL__"; + +function handlerFilterKey(handlerName: string | null | undefined): string { + const name = handlerName?.trim() ?? ""; + return name || HANDLER_UNASSIGNED; +} + const WorkbenchTicketReleaseTableTab: React.FC = () => { const { t } = useTranslation("ticketReleaseTable"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -73,6 +84,7 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { const [queryDate, setQueryDate] = useState(() => dayjs()); const [selectedFloor, setSelectedFloor] = useState(""); const [selectedStatus, setSelectedStatus] = useState("released"); + const [selectedHandlers, setSelectedHandlers] = useState([]); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [now, setNow] = useState(dayjs()); @@ -111,6 +123,28 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { }, []); const dayStr = queryDate.format("YYYY-MM-DD"); + + const handlerOptions = useMemo(() => { + const names = new Set(); + let hasUnassigned = false; + for (const item of data) { + if (selectedFloor && item.storeId !== selectedFloor) continue; + const name = item.handlerName?.trim(); + if (name) names.add(name); + else hasUnassigned = true; + } + const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, "zh-Hant")); + return hasUnassigned ? [HANDLER_UNASSIGNED, ...sorted] : sorted; + }, [data, selectedFloor]); + + useEffect(() => { + setSelectedHandlers((prev) => { + if (prev.length === 0) return prev; + const next = prev.filter((name) => handlerOptions.includes(name)); + return next.length === prev.length ? prev : next; + }); + }, [handlerOptions]); + const filteredData = useMemo(() => { return data.filter((item) => { if (selectedFloor && item.storeId !== selectedFloor) return false; @@ -119,9 +153,27 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { if (itemDate !== dayStr) return false; } if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false; + if (selectedHandlers.length > 0 && !selectedHandlers.includes(handlerFilterKey(item.handlerName))) { + return false; + } return true; }); - }, [data, dayStr, selectedFloor, selectedStatus]); + }, [data, dayStr, selectedFloor, selectedStatus, selectedHandlers]); + + const allHandlersSelected = + handlerOptions.length > 0 && selectedHandlers.length === handlerOptions.length; + + const handleHandlersChange = (event: SelectChangeEvent) => { + const value = event.target.value; + const next = typeof value === "string" ? value.split(",") : value; + if (next.includes(HANDLER_SELECT_ALL)) { + setSelectedHandlers(allHandlersSelected ? [] : handlerOptions); + setPaginationController((prev) => ({ ...prev, pageNum: 0 })); + return; + } + setSelectedHandlers(next.filter((v) => v !== HANDLER_SELECT_ALL)); + setPaginationController((prev) => ({ ...prev, pageNum: 0 })); + }; const paginatedData = useMemo(() => { const startIndex = paginationController.pageNum * paginationController.pageSize; @@ -247,6 +299,39 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { {t("completed")} + + + {t("Handler Name")} + + + diff --git a/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx b/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx index 125ece2..53cad14 100644 --- a/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx +++ b/src/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal.tsx @@ -36,7 +36,9 @@ export type ReleasedDoPickListBridge = { shopName?: string, storeId?: string, truck?: string, - releaseType?: string + releaseType?: string, + /** Optional `2F`/`4F` for Truck X supplier-floor split */ + floor?: string ) => Promise; /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ loadToday: ( @@ -44,7 +46,9 @@ export type ReleasedDoPickListBridge = { storeId?: string, truck?: string, requiredDeliveryDate?: string, - releaseType?: string + releaseType?: string, + /** Optional `2F`/`4F` for Truck X supplier-floor split */ + floor?: string ) => Promise; assignByListItemId: (userId: number, id: number) => Promise; }; @@ -70,6 +74,10 @@ interface Props { * requiredDate instead of historical released (delivery date before calendar today). */ filterRequiredDeliveryDate?: string; + /** + * Truck X only: `2F`/`4F` (or `2/F`/`4/F`) — filter by DO supplier preferred floor; storeId stays null. + */ + truckXFloor?: string; } const ReleasedDoPickOrderSelectModal: React.FC = ({ @@ -85,6 +93,7 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({ releaseTypeFilter, initialShopSearch, filterRequiredDeliveryDate, + truckXFloor, }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -102,6 +111,7 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({ const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection; const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday; + const floorArg = truckXFloor?.trim() || undefined; if (isDefaultTruck) { if (defaultDateScopeProp === "today") { data = await loadTodayFn( @@ -109,14 +119,16 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({ undefined, "車線-X", defaultTruckRequiredDeliveryDate?.trim() || undefined, - releaseTypeFilter?.trim() || undefined + releaseTypeFilter?.trim() || undefined, + floorArg ); } else { data = await loadReleased( undefined, undefined, "車線-X", - releaseTypeFilter?.trim() || undefined + releaseTypeFilter?.trim() || undefined, + floorArg ); } } else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) { @@ -143,7 +155,7 @@ const ReleasedDoPickOrderSelectModal: React.FC = ({ } finally { setLoading(false); } - }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]); + }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate, truckXFloor]); useEffect(() => { loadList(); diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 30d95d6..40843a7 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -1655,6 +1655,11 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const isRowExpired = isWorkbenchSourceLotExpired(r) && !isRowRejected; const isRowUnavailable = isInventoryLotLineUnavailable(r); + const isRowComplete = + rowStatus === "completed" || + rowStatus === "checked" || + rowStatus === "partially_completed" || + rowStatus === "partially_complete"; return ( @@ -1678,7 +1683,9 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { ? "error.main" : isRowExpired || isLotAvailabilityExpired(r) ? "warning.main" - : "inherit", + : isRowComplete + ? "success.main" + : "inherit", }} > {r.lotNo ? ( @@ -1833,7 +1840,19 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return { ...prev, [r.stockOutLineId]: n }; }); }} - sx={{ width: 96 }} + sx={{ + width: 96, + "& .MuiInputBase-input": { + typography: "body1", + py: 0.5, + textAlign: "center", + "&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: 0, + }, + MozAppearance: "textfield", + }, + }} disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true} inputProps={{ min: 0, step: 1 }} /> diff --git a/src/i18n/en/ticketReleaseTable.json b/src/i18n/en/ticketReleaseTable.json index 7af4ded..6fc5468 100644 --- a/src/i18n/en/ticketReleaseTable.json +++ b/src/i18n/en/ticketReleaseTable.json @@ -1,6 +1,7 @@ { "Actions": "Actions", "All Floors": "All Floors", + "All Handlers": "All Handlers", "All Statuses": "All Statuses", "Auto-refresh every 1 minute": "Auto-refresh every 1 minute", "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", @@ -30,9 +31,11 @@ "Revert assignment": "Revert assignment", "Revert assignment hint": "Revert assignment hint", "Rows per page": "Rows per page", + "Select All": "Select All", "Select Date": "Select Date", "Shop Name": "Shop Name", "Status": "Status", + "Unassigned": "Unassigned", "Store ID": "Store ID", "Target Date": "Target Date", "Ticket Information": "Ticket Information", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 0a95cb6..d9465c5 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -12,7 +12,7 @@ "N/A": "不適用", "Release Pick Orders": "放單", "released": "已放單", - "is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的 QR 碼。", + "is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的二維碼。", "No lot rows. Select a pick order above.": "沒有批次行。請選擇一個提料單。", "Loading...": "載入中...", "Suggestion success": "建議成功", @@ -65,7 +65,7 @@ "items": "項目", "Select Pick Order:": "選擇提料單:", "No Stock Available": "沒有庫存", - "is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的 QR 碼。", + "is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的二維碼。", "Start Fail": "開始失敗", "Start PO": "開始採購訂單", "Do you want to complete?": "確定完成嗎?", @@ -136,7 +136,7 @@ "LotNo": "批號", "Po Code": "採購訂單編號", "No Warehouse": "沒有倉庫", - "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", + "Please scan warehouse qr code.": "請掃描倉庫二維碼。", "Reject": "拒絕", "submit": "確認提交", @@ -270,7 +270,7 @@ "Pick order completed successfully!": "提料單完成成功!", "Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。", "This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。", - "Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。", + "Please finish QR code scan, QC check and pick order.": "請完成二維碼掃描、QC 檢查和提料。", "No data available": "沒有資料", "Please submit the pick order.": "請提交提料單。", "Item lot to be Pick:": "批次貨品提料:", @@ -293,13 +293,13 @@ "Original Available Qty": "原可用數", "Remaining Available Qty": "剩餘可用數", "Please submit pick order.": "請提交提料單。", - "Please finish QR code scan and pick order.": "請完成 QR 碼掃描和提料。", - "Please finish QR code scanand pick order.": "請完成 QR 碼掃描和提料。", + "Please finish QR code scan and pick order.": "請完成二維碼掃描和提料。", + "Please finish QR code scanand pick order.": "請完成二維碼掃描和提料。", "First created group": "首次建立分組", "Latest created group": "最新建立分組", "Manual Input": "手動輸入", - "QR Code Scan for Lot": " QR 碼掃描批次", - "Processing QR code...": "處理 QR 碼...", + "QR Code Scan for Lot": "二維碼掃描批次", + "Processing QR code...": "處理二維碼...", "The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。", "Verified successfully!": "驗證成功!", "Cancel": "取消", @@ -370,7 +370,7 @@ "Scanning...":"掃描中...", "Print DN/Label":"列印送貨單/標籤", "Store ID":"儲存編號", - "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", + "QR code does not match any item in current orders.":"二維碼不符合當前訂單中的任何貨品。", "Lot Number Mismatch":"批次號碼不符", "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", "The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。", @@ -389,7 +389,7 @@ "Lot switch failed":"批次切換失敗", "The system could not switch to the scanned lot. Review the lots below, then tap Confirm to retry.":"系統無法切換至掃描的批次。請核對下方批次後按「確認」重試。", "You can also scan again: expected lot QR keeps the suggested line; scanned lot QR retries the switch.":"您也可以再掃描:掃描建議批次 QR 可保留該行;掃描欲切換批次 QR 可再次嘗試切換。", - "QR code verified.":"QR 碼驗證成功。", + "QR code verified.":"二維碼驗證成功。", "Order Finished":"訂單完成", "Submitted Status":"提交狀態", "Pick Execution Record":"提料執行記錄", @@ -507,16 +507,17 @@ "packaging": "提料中", "No Stock Available": "沒有庫存可用", "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", - "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位與提貨行不符,請掃描其他批號。", - "Scanned lot UOM does not match pick line UOM": "此批號單位與提貨行不符,請掃描其他批號。", + "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位不符,請掃描其他批號。", + "Scanned lot UOM does not match pick line UOM": "此批號單位不符,請掃描其他批號。", "This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。", "Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。", - "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。", + "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用二維碼。", "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", "Day After Tomorrow": "後日", "Lot line is unavailable": "掃描批次不可用", "Select Date": "請選擇日期", - "Suggest Lot No.": "推薦批號", + + "Suggest Lot No.": "推薦批號/實際批號", "Search by Shop": "搜索商店", "Search by Truck": "搜索貨車", "Print DN & Label": "列印提料單和送貨單標籤", @@ -545,13 +546,13 @@ "4F ticket": "4/F 票", "4F lane panel legend": "貨車班次 — 裝載序(未撳數/總單數)", "Loading sequence n": "板{{n}}", - "lot QR code": "批號 QR 碼", + "lot QR code": "批號二維碼", "label Printer" : "標籤打印機", "A4 Printer" : "A4 打印機", "Loading Sequence": "裝載序", "Ticket No": "提票號碼", "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "掃描的庫存批行為「不可用」,無法換批或綁定;揀貨行未更新。", - "is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的 QR 碼。", + "is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的二維碼。", "Lot switch failed; pick line was not marked as checked.": "換批失敗;揀貨行未標為已核對。", "Lot confirmation failed. Please try again.": "確認批號失敗,請重試。", "Powder Mixture": "箱料粉", diff --git a/src/i18n/zh/ticketReleaseTable.json b/src/i18n/zh/ticketReleaseTable.json index fcdaa61..a0469a9 100644 --- a/src/i18n/zh/ticketReleaseTable.json +++ b/src/i18n/zh/ticketReleaseTable.json @@ -25,6 +25,9 @@ "Released Time": "開始時間", "Completed Time": "完成時間", "Handler Name": "負責員工", + "All Handlers": "所有員工", + "Select All": "全選", + "Unassigned": "未分配", "Number of FG Items (Order Item(s) Count)": "訂單項目數量", "No data available": "沒有資料", "Rows per page": "每頁行數", From 83b6203e615e00552d0b26a7b42374705d155224 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Thu, 23 Jul 2026 13:56:12 +0800 Subject: [PATCH 14/16] translation fix --- src/app/(main)/settings/itemPrice/page.tsx | 2 +- .../ItemPriceSearch/ItemPriceSearch.tsx | 6 +- src/i18n/en/inventory.json | 132 +-------- src/i18n/zh/inventory.json | 262 +++++------------- 4 files changed, 83 insertions(+), 319 deletions(-) diff --git a/src/app/(main)/settings/itemPrice/page.tsx b/src/app/(main)/settings/itemPrice/page.tsx index 9b386b3..72bae26 100644 --- a/src/app/(main)/settings/itemPrice/page.tsx +++ b/src/app/(main)/settings/itemPrice/page.tsx @@ -15,7 +15,7 @@ const ItemPriceSetting: React.FC = async () => { <> - + }> diff --git a/src/components/ItemPriceSearch/ItemPriceSearch.tsx b/src/components/ItemPriceSearch/ItemPriceSearch.tsx index 54556e9..6d7f6fa 100644 --- a/src/components/ItemPriceSearch/ItemPriceSearch.tsx +++ b/src/components/ItemPriceSearch/ItemPriceSearch.tsx @@ -24,7 +24,7 @@ type ItemPriceSearchComponent = React.FC & { type SearchParamNames = keyof SearchQuery; const ItemPriceSearch: ItemPriceSearchComponent = () => { - const { t } = useTranslation(["itemPrice", "inventory", "common", "importExcel"]); + const { t } = useTranslation(["itemPrice", "common", "importExcel"]); const [item, setItem] = useState(null); const [isSearching, setIsSearching] = useState(false); @@ -275,7 +275,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => { - {t("Average unit price", { ns: "inventory" })} + {t("Average unit price")} {avgPrice != null && avgPrice !== 0 @@ -292,7 +292,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => { - {t("Latest market unit price", { ns: "inventory" })} + {t("Latest market unit price")} {item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0 diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 1bb7e57..3dcb7bd 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -1,75 +1,28 @@ { - "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out", - "AVAILABLE": "AVAILABLE", "Action": "Action", "Add": "Add", "Add entry": "Add entry", "Add entry for items without inventory": "Add entry for items without inventory", "Adjusted Qty": "Adjusted Qty", - "Approve": "Approve", - "Approver All": "Approver All", - "Approver search empty hint": "Set search criteria and click Search", - "ApproverAll": "ApproverAll", - "Available": "Available", "Available Qty": "Available Qty", - "Available Qty Per Smallest Unit": "Available Qty Per Smallest Unit", - "Average unit price": "Average unit price", - "Back": "Back", - "Bad Item Handle": "Bad Item Handle", - "Bad Item Qty": "Bad Item Qty", - "Bad Item Records": "Bad Item Records", - "Balance Qty": "Balance Qty", - "Base UoM": "Base UoM", - "Batch Save Completed": "Batch Save Completed", - "Batch Save Inputted": "Batch Save Inputted", - "Batch Submit All": "Batch Submit All", - "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors", - "Body is unavailable": "Body is unavailable", - "Bom Req. Qty": "Bom Req. Qty", "CMB": "Consumables", "CO": "Consumables", - "Clear selection": "Clear selection", + "Cancel": "Cancel", "Code": "Code", - "Collapse floor sections": "Collapse sections for this floor", - "Confirm Adjustment": "Confirm Adjustment", - "Confirm create stock take for all sections?": "Confirm create stock take for all sections?", "Confirm remove": "Confirm remove", - "Counted Qty": "Counted Qty", - "Create Stock Take for All Sections": "Create Stock Take for All Sections", "Current Stock": "Current Stock", - "Delivery Order": "Delivery Order", "Difference": "Difference", "Download QR Code": "Download QR Code", "Edit mode": "Edit mode", - "End Date": "End Date", "Enter item code or name to search": "Enter item code or name to search", - "Expand floor sections": "Expand sections for this floor", "Expiry Date": "Expiry Date", - "Expiry End Date": "Expiry End Date", - "Expiry Item Handle": "Expiry Item Handle", - "Expiry Item Qty": "Expiry Item Qty", - "Expiry Item Records": "Expiry Item Records", - "Expiry Qty": "Expiry Qty", - "Expiry Start Date": "Expiry Start Date", "FG": "Finished good", "Failed to transfer stock": "Failed to transfer stock", "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", - "Filtered out": "Filtered out", - "First Qty": "First Qty", - "Handled Date": "Handled Date", - "Handler": "Handler", - "Hide Search Filters": "Hide Search Filters", - "In Qty": "In Qty", - "Input": "Input", - "Invalid QTY": "Invalid quantity", "Inventory": "Inventory", - "Inventory Exception Management": "Inventory Exception Management", - "Item Name": "Item Name", + "Item": "Item", "Item selected": "Item selected", - "Item-lotNo": "Item-lotNo", - "Job Order": "Job Order", - "Latest market unit price": "Latest market unit price", - "Loading": "Loading", + "Location": "Location", "Lot No": "Lot No", "MA": "Material", "MI": "Miscellaneous", @@ -77,130 +30,59 @@ "NM": "Miscellaneous / non-consumables", "Name": "Name", "No Data": "No Data", - "No changes to submit": "No changes to submit", - "No issues found": "No issues found", + "No data": "No data", "No items are selected yet.": "No items are selected yet.", "No lot no entered, will be generated by system.": "No lot no entered, will be generated by system.", - "No record found": "No record found", "Opening Inventory": "Opening Inventory", "Optional - system will generate": "Optional - system will generate", "Original Qty": "Original Qty", - "Out Qty": "Out Qty", - "Perform Stock Take": "Perform Stock Take", - "Pick Order, Issue No, Item, Lot...": "Pick Order, Issue No, Item, Lot...", - "Please enter QTY and Bad QTY": "Please enter QTY and Bad QTY", "Please scan...": "Please scan...", - "Please set at least one search criterion": "Please set at least one search criterion", "Print": "Print", "Print QR Code": "Print QR Code", "Print Qty": "Print Qty", "Print failed": "Print failed", "Print sent": "Print sent", "Printer": "Printer", - "Qty": "Qty", "Qty To Be Transferred": "Qty To Be Transferred", - "Quantity exceeds available quantity": "Quantity exceeds available quantity", "RM": "Raw material", "Reason for adjustment": "Reason for adjustment", "Reason for removal": "Reason for removal", - "Refresh": "Refresh", "Remaining Qty": "Remaining Qty", "Remarks": "Remarks", "Remove": "Remove", - "Resolved": "Resolved", - "Review Variance": "Review Variance", - "Rows per page": "Rows per page", + "Reset": "Reset", "SFG": "Semi-finished good", - "Sales UoM": "Sales UoM", + "Save": "Save", "Save failed": "Save failed", "Saved successfully": "Saved successfully", "Search lot by QR code": "Search lot by QR code", - "Search to load lot lines": "Search to load lot lines", - "Second Qty": "Second Qty", - "Select": "Select", - "Select Section": "Select Section", - "Select Stock Take Section": "Select Stock Take Section", - "Select all sections": "Select all sections", - "Select sections placeholder": "Select one or more", - "Select stock take sections to create hint": "Choose warehouse stock take sections to start a new round (selected sections share one new round id in this batch).", - "Selected section count": "Selected: {{count}} section(s)", - "Show Search Filters": "Show Search Filters", - "Something went wrong fetching data in server.": "Something went wrong fetching data in server.", - "Start Date": "Start Date", "Start Location": "Start Location", - "Start New Stock Take": "Start New Stock Take", - "Start Time": "Start Time", "Stock Adjustment": "Stock Adjustment", - "Stock Record": "Stock Record", - "Stock Take Variance": "Stock Take Variance", "Stock Transfer": "Stock Transfer", "Stock UoM": "Stock UoM", - "Stock take adjustment confirmed! (Demo only)": "Stock take adjustment confirmed! (Demo only)", - "Stock take adjustment has been confirmed successfully!": "Stock take adjustment has been confirmed successfully!", - "Stock take qty exceeds maximum": "Stock take quantity cannot exceed 999,999,999,999", "Stock transfer created new lot": "Stock transfer completed (created new lot line).", "Stock transfer merged ambiguous": "Merged into the earliest available batch (multiple available lines for the same lot).", "Stock transfer merged existing lot": "Stock transfer completed (merged into existing lot).", "Stock transfer successful": "Stock transfer completed", "Stop QR Scan": "Stop QR Scan", "Submit": "Submit", - "Submit completed: {{success}} success, {{errors}} errors": "Submit completed: {{success}} success, {{errors}} errors", - "System Qty": "System Qty", "Target Location": "Target Location", - "Total Approved": "Total Approved", - "Total Issues": "Total Issues", - "Total Item Number": "Total Item Number", - "Total Stock Takes": "Total Stock Takes", - "Total need stock take": "Total need stock take", "Type": "Type", - "UNAVAILABLE": "UNAVAILABLE", - "Variance filter strict bounds": "Exclude boundaries (use > <)", - "View": "View", + "UoM": "UoM", "WIP": "Work in progress", - "Waiting for Approver": "Waiting for Approver", "Warehouse": "Warehouse", - "adj": "adj", - "approver": "approver", - "approving": "approving", - "available": "available", - "bad": "bad", "cmb": "Consumables", - "complete": "complete", - "completed": "completed", - "completed by": "completed by", - "completed date": "completed date", - "completed remarks": "completed remarks", - "completed status": "completed status", "consumable": "Consumable", "consumables": "Consumables", "dnNo": "dnNo", - "expiry": "expiry", "fg": "Finished good", "item": "Item", "mat": "Raw material", - "miss": "miss", "nm": "Miscellaneous / non-consumables", "non-consumables": "Non-consumables", - "nor": "nor", - "not available": "not available", - "not match": "not match", - "not pass": "not pass", - "notMatch": "notMatch", - "notmatch": "notmatch", - "open": "open", - "pass": "pass", - "pending": "pending", "productLotNo": "productLotNo", - "rejected": "rejected", - "save": "save", - "section": "section", "sfg": "Semi-finished good", - "stockTaking": "stockTaking", - "tke": "tke", "to": "to", - "trf": "trf", - "unavailable": "unavailable", - "variance": "variance", "wip": "Work in progress", "材料": "Material" } diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index f418c6a..fae7d82 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -1,206 +1,88 @@ { - "Inventory": "存貨", + "Action": "操作", + "Add": "新增", + "Add entry": "新增倉存", + "Add entry for items without inventory": "為無庫存貨品新增倉存", + "Adjusted Qty": "調整後倉存", + "Available Qty": "可用數量", + "CMB": "消耗品", + "CO": "消耗品", + "Cancel": "取消", "Code": "編號", - "Name": "名稱", - "Type": "類型", - "Lot No": "批號", + "Confirm remove": "確認移除", + "Current Stock": "現有庫存", + "Difference": "差異", + "Download QR Code": "下載", + "Edit mode": "編輯模式", + "Enter item code or name to search": "輸入貨品編號或名稱以搜索", "Expiry Date": "到期日", - "Warehouse": "倉庫", - "材料": "材料", - "consumable": "消耗品", - "Qty": "盤點數量", - "Total need stock take": "總需盤點數量", - "Waiting for Approver": "待審核數量", - "Total Approved": "已審核數量", - "mat": "原料", - "variance": "差異", - "ApproverAll": "審核員", - "Approver All": "審核員全部盤點", - "fg": "成品", "FG": "成品", - "sfg": "半成品", - "SFG": "半成品", - "consumables": "消耗品", - "non-consumables": "非消耗品", - "item": "貨品", - "NM": "雜項及非消耗品", - "CMB": "消耗品", - "RM": "原料", + "Failed to transfer stock": "轉倉失敗", + "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", + "Inventory": "存貨", + "Item": "貨品", + "Item selected": "已選擇貨品", + "Location": "位置", + "Lot No": "批號", "MA": "材料", - "CO": "消耗品", "MI": "雜項", - "wip": "半成品", - "WIP": "半成品", - "cmb": "消耗品", - "nm": "雜項及非消耗品", - "available": "可用", - "unavailable": "不可用", - "tke": "盤點", - "Total Stock Takes": "總盤點數量", - "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 失敗", - "Body is unavailable": "輸入不可用", - "Confirm create stock take for all sections?": "確認為所有區域創建盤點?", - "not available": "不可用", - "Batch Submit All": "批量提交所有", - "not match": "要求重點", - "Show Search Filters": "顯示搜索器", - "Hide Search Filters": "隱藏搜索器", - "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", - "Variance filter strict bounds": "不使用=", - "Filtered out": "過濾掉", - "Total Item Number": "貨品數量", - "Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量", - "Invalid QTY": "無效的數量", - "Stock take qty exceeds maximum": "盤點數量不可超過 999,999,999,999", - "Start Time": "開始時間", - "stockTaking": "盤點中", - "rejected": "已拒絕", - "miss": "缺貨", - "bad": "不良", - "expiry": "過期", - "open": "開倉", - "Bom Req. Qty": "需求數(BOM單位)", - "notmatch": "要求重點", - "pass": "已盤點", - "not pass": "不通過", - "Available": "可用", - "approving": "審核中", - "pending": "待處理", - "notMatch": "要求重點", - "Input": "輸入", - "Something went wrong fetching data in server.": "在伺服器上取得資料時發生錯誤。", - "save": "保存", - "AVAILABLE": "可用", - "View": "查看", - "approver": "審核員", - "Create Stock Take for All Sections": "為所有區域創建盤點", - "Select stock take sections to create hint": "請選擇要建立新盤點輪次的盤點區域(同一批次將共用同一輪次編號)。", - "Select sections placeholder": "可多選", - "Select all sections": "全選區域", - "Clear selection": "清除選取", - "Selected section count": "已選擇 {{count}} 個區域", - "Expand floor sections": "展開此樓層區域", - "Collapse floor sections": "收合此樓層區域", - "section": "區域", - "First Qty": "第一次盤點數量", - "Second Qty": "第二次盤點數量", - "Remarks": "備註", - "Available Qty": "可用數量", - "Sales UoM": "銷售單位", - "Stock UoM": "庫存單位", - "Available Qty Per Smallest Unit": "可用數量 (基本單位)", - "Base UoM": "基本單位", - "No items are selected yet.": "未選擇貨品", - "Item selected": "已選擇貨品", - "Delivery Order": "交貨單", - "Job Order": "工單", "Material": "物料", - "UNAVAILABLE": "不可用", - "No issues found": "未找到問題", - "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆", - "Approve": "審核", - "complete": "完成", - "completed": "已完成", - "completed by": "完成者", - "completed date": "完成日期", - "completed remarks": "完成備註", - "completed status": "完成狀態", - "Pick Order, Issue No, Item, Lot...": "揀貨單, 問題編號, 貨品, 批號...", - "Refresh": "刷新", - "Resolved": "已解決", - "Total Issues": "總問題數量", - "Inventory Exception Management": "存貨異常管理", - "Back": "返回", - "Confirm Adjustment": "確認調整", - "Counted Qty": "盤點數量", - "Item Name": "貨品名稱", - "Perform Stock Take": "執行盤點", - "Review Variance": "審核差異", - "Select": "選擇", - "Select Section": "選擇區域", - "Select Stock Take Section": "選擇盤點區域", - "Start New Stock Take": "開始新的盤點", - "Stock Take Variance": "盤點差異", - "Stock take adjustment confirmed! (Demo only)": "盤點調整確認!(僅演示)", - "Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!", - "System Qty": "系統數量", - "Batch Save Inputted": "批量保存已輸入", - "Batch Save Completed": "批量保存完成", - "Bad Item Handle": "不良品處理", - "Search to load lot lines": "請按搜索以載入批號", - "No changes to submit": "沒有可提交的變更", - "No record found": "沒有記錄", - "Rows per page": "每頁行數", - "Bad Item Records": "不良品處理紀錄", - "Expiry Item Handle": "過期品處理", - "Expiry Item Records": "過期品處理紀錄", - "Handled Date": "處理日期", - "Expiry Start Date": "到期日(開始)", - "Expiry End Date": "到期日(結束)", - "Bad Item Qty": "不良品數量", - "Expiry Item Qty": "過期品數量", - "Handler": "處理人", - "Quantity exceeds available quantity": "數量超過可用數量", - "Stock Record": "庫存記錄", - "Item-lotNo": "貨品-批號", - "In Qty": "入庫數量", - "Expiry Qty": "過期數量", - "Out Qty": "出庫數量", - "Balance Qty": "庫存數量", - "Start Date": "開始日期", - "End Date": "結束日期", - "Loading": "加載中", - "adj": "調整", - "nor": "正常", - "trf": "轉倉", - "Stock transfer successful": "轉倉成功", - "Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)", - "Stock transfer merged existing lot": "轉倉成功(已併入既有批號)", - "Stock transfer created new lot": "轉倉成功(已建立新批號庫存)", - "Failed to transfer stock": "轉倉失敗", - "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", - "Download QR Code": "下載", - "Print QR Code": "列印", - "Stock Transfer": "轉倉", - "Start Location": "起點倉位", - "to": "轉倉至", - "Target Location": "目標倉位", + "NM": "雜項及非消耗品", + "Name": "名稱", + "No Data": "沒有數據", + "No data": "沒有數據", + "No items are selected yet.": "未選擇貨品", + "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", + "Opening Inventory": "開倉", + "Optional - system will generate": "選填,系統將自動生成", "Original Qty": "原有數量", - "Qty To Be Transferred": "待轉數量", - "Remaining Qty": "剩餘數量", - "Submit": "確認", - "Printer": "列印機", - "Print Qty": "列印數量", + "Please scan...": "請掃描...", "Print": "列印", - "Print sent": "已送出列印", + "Print QR Code": "列印", + "Print Qty": "列印數量", "Print failed": "列印失敗", - "Stock Adjustment": "庫存調整", - "Edit mode": "編輯模式", - "Add entry": "新增倉存", - "productLotNo": "產品批號", - "dnNo": "送貨單編號", - "Optional - system will generate": "選填,系統將自動生成", - "Add": "新增", - "Opening Inventory": "開倉", + "Print sent": "已送出列印", + "Printer": "列印機", + "Qty To Be Transferred": "待轉數量", + "RM": "原料", "Reason for adjustment": "調整原因", - "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", "Reason for removal": "移除原因", - "Confirm remove": "確認移除", - "Adjusted Qty": "調整後倉存", - "Difference": "差異", - "Action": "操作", - "Saved successfully": "儲存成功", - "Save failed": "儲存失敗", + "Remaining Qty": "剩餘數量", + "Remarks": "備註", "Remove": "移除", - "Average unit price": "平均單位價格", - "Latest market unit price": "最新市場價格", - "Add entry for items without inventory": "為無庫存貨品新增倉存", - "Enter item code or name to search": "輸入貨品編號或名稱以搜索", - "Current Stock": "現有庫存", + "Reset": "重置", + "SFG": "半成品", + "Save": "儲存", + "Save failed": "儲存失敗", + "Saved successfully": "儲存成功", "Search lot by QR code": "尋找批次(掃描二維碼)", - "Please scan...": "請掃描...", + "Start Location": "起點倉位", + "Stock Adjustment": "庫存調整", + "Stock Transfer": "轉倉", + "Stock UoM": "庫存單位", + "Stock transfer created new lot": "轉倉成功(已建立新批號庫存)", + "Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)", + "Stock transfer merged existing lot": "轉倉成功(已併入既有批號)", + "Stock transfer successful": "轉倉成功", "Stop QR Scan": "停止掃碼", - "No Data": "沒有數據", - "Please set at least one search criterion": "請至少設定一項搜索條件", - "Approver search empty hint": "請設定搜索條件後點擊搜索" + "Submit": "確認", + "Target Location": "目標倉位", + "Type": "類型", + "UoM": "單位", + "WIP": "半成品", + "Warehouse": "倉庫", + "cmb": "消耗品", + "consumable": "消耗品", + "consumables": "消耗品", + "dnNo": "送貨單編號", + "fg": "成品", + "item": "貨品", + "mat": "原料", + "nm": "雜項及非消耗品", + "non-consumables": "非消耗品", + "productLotNo": "產品批號", + "sfg": "半成品", + "to": "轉倉至", + "wip": "半成品", + "材料": "材料" } From fcf0037bf2d689dae721132c785ce6f9dd80a8f7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 27 Jul 2026 14:26:13 +0800 Subject: [PATCH 15/16] isextra truck X ticket fix --- .../DoWorkbench/WorkbenchFloorLanePanel.tsx | 34 ++++++++++++++----- .../ReleasedDoPickOrderSelectModal.tsx | 6 ++-- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx index 9d74845..76b722b 100644 --- a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx +++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx @@ -40,6 +40,7 @@ interface Props { type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; +/** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitchToDetailTab, @@ -689,11 +690,17 @@ const WorkbenchFloorLanePanel: React.FC = ({ group.lanes.map((lane, li) => { const sid = (lane.storeId ?? "").trim(); const dep = (lane.truckDepartureTime ?? "").trim(); - const is4F = sid.replace(/\//g, "").toUpperCase() === "4F"; + const isTruckX = + (lane.truckLanceCode ?? "").trim() === "車線-X" || + (lane.truckLanceCode ?? "").trim() === "Truck X"; + const floorKey = sid.replace(/\//g, "").toUpperCase(); + const is4F = floorKey === "4F"; const labelCore = - is4F && lane.loadingSequence != null - ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` - : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; + isTruckX + ? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})` + : is4F && lane.loadingSequence != null + ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` + : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; const handlerName = (lane.handlerName ?? "").trim(); const shopCode = (group.shopCode ?? "").trim(); const shopName = (group.shopName ?? "").trim(); @@ -702,20 +709,29 @@ const WorkbenchFloorLanePanel: React.FC = ({ ? `${shopCode} · ${shopName}` : shopName || shopCode || t("Shop"); const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`; - const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${li}`; + const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${sid}|${li}`; return ( + + + - {t("View Details")} - - - + {pickOrder.jobOrderCode || "-"} + {" · "} + {t("Lot No")}: {pickOrder.lotNo || "-"} + + ); diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx index 3458235..10a4c66 100644 --- a/src/components/JoWorkbench/newJobPickExecution.tsx +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -618,6 +618,7 @@ const QrCodeModal: React.FC<{ ); }; +/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); @@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; let switchedToUnavailable = false; - // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo - if (!originalSuggestedPickLotId) { - if (!selectedLotForQr?.stockOutLineId) { - throw new Error("Missing stockOutLineId for noLot line"); - } - console.log( - "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", - ); - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: selectedLotForQr.pickOrderLineId, - inventoryLotNo: effectiveScannedLot.lotNo || "", - stockInLineId: effectiveScannedLot?.stockInLineId ?? null, - stockOutLineId: selectedLotForQr.stockOutLineId, - itemId: selectedLotForQr.itemId, - status: "checked", - }); - console.log( - "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", - res, - ); - switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; - const ok = - res?.code === "checked" || - res?.code === "SUCCESS" || - switchedToUnavailable; - if (!ok) { - const errMsg = - res?.code === "LOT_UNAVAILABLE" - ? tPick( - "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", - ) - : res?.message || - tPick( - "Lot switch failed; pick line was not marked as checked.", - ); - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - return; - } - } else { - // Call confirmLotSubstitution to update the suggested lot - console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); - const substitutionResult = await confirmLotSubstitution({ - pickOrderLineId: selectedLotForQr.pickOrderLineId, - stockOutLineId: selectedLotForQr.stockOutLineId, - originalSuggestedPickLotId, - newInventoryLotNo: effectiveScannedLot.lotNo || "", - // ✅ required by LotSubstitutionConfirmRequest - newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, - }); - - console.log( - "✅ [LOT CONFIRM] Lot substitution result:", - substitutionResult, - ); + // Workbench no-hold: skip classic bind/switch (holdQty). Switch + pick via scan-pick only. + // Non-workbench: keep confirmLotSubstitution / QR bind (moves hold). + if (!workbenchMode) { + // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo + if (!originalSuggestedPickLotId) { + if (!selectedLotForQr?.stockOutLineId) { + throw new Error("Missing stockOutLineId for noLot line"); + } + console.log( + "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", + ); + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: selectedLotForQr.pickOrderLineId, + inventoryLotNo: effectiveScannedLot.lotNo || "", + stockInLineId: effectiveScannedLot?.stockInLineId ?? null, + stockOutLineId: selectedLotForQr.stockOutLineId, + itemId: selectedLotForQr.itemId, + status: "checked", + }); + console.log( + "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", + res, + ); + switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; + const ok = + res?.code === "checked" || + res?.code === "SUCCESS" || + switchedToUnavailable; + if (!ok) { + const errMsg = + res?.code === "LOT_UNAVAILABLE" + ? tPick( + "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", + ) + : res?.message || + tPick( + "Lot switch failed; pick line was not marked as checked.", + ); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } + } else { + // Call confirmLotSubstitution to update the suggested lot + console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: selectedLotForQr.pickOrderLineId, + stockOutLineId: selectedLotForQr.stockOutLineId, + originalSuggestedPickLotId, + newInventoryLotNo: effectiveScannedLot.lotNo || "", + // ✅ required by LotSubstitutionConfirmRequest + newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, + }); - // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. - // Keep modal open so user can cancel/rescan. - switchedToUnavailable = - substitutionResult?.code === "SUCCESS_UNAVAILABLE" || - substitutionResult?.code === "BOUND_UNAVAILABLE"; - if ( - !substitutionResult || - (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) - ) { - console.error( - "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", + console.log( + "✅ [LOT CONFIRM] Lot substitution result:", + substitutionResult, ); - const errMsg = - substitutionResult?.code === "LOT_UNAVAILABLE" - ? tPick( - "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", - ) - : substitutionResult?.message || - `换批失败:stockInLineId ${ - effectiveScannedLot?.stockInLineId ?? "" - } 不存在或无法匹配`; - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - return; + + // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. + // Keep modal open so user can cancel/rescan. + switchedToUnavailable = + substitutionResult?.code === "SUCCESS_UNAVAILABLE" || + substitutionResult?.code === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) + ) { + console.error( + "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", + ); + const errMsg = + substitutionResult?.code === "LOT_UNAVAILABLE" + ? tPick( + "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", + ) + : substitutionResult?.message || + `换批失败:stockInLineId ${ + effectiveScannedLot?.stockInLineId ?? "" + } 不存在或无法匹配`; + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } } } diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 40843a7..98d3442 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -30,7 +30,6 @@ import { SessionWithTokens } from "@/config/authConfig"; import { fetchConsumableWorkbenchPickOrderLotsHierarchical, reloadConsumableWorkbenchPickOrderLotsHierarchical, - confirmLotSubstitution, suggestPickOrderWorkbenchV2, } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; @@ -229,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean => const isRejectedStatus = (status: string | undefined): boolean => String(status || "").toLowerCase() === "rejected"; -const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { - const c = String(code || "").toUpperCase(); - const m = String(message || ""); - if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true; - if (/^Reject switch lot:/i.test(m)) return true; - if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true; - return false; -}; - function safeDisplayTargetDate(targetDate: string | number[]): string { try { if (Array.isArray(targetDate) && targetDate.length >= 3) { @@ -356,7 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { }); } -/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -962,6 +952,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { } }, []); + // Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only. const handleLotConfirmation = useCallback( async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { const expected = overrideExpected ?? expectedLotData; @@ -972,56 +963,25 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { setError(""); setMessage(""); try { - const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0); - let switchedToUnavailable = false; - if (originalSuggestedPickLotId > 0) { - const res = await confirmLotSubstitution({ - pickOrderLineId: expected.row.pickOrderLineId, - stockOutLineId: expected.row.stockOutLineId, - originalSuggestedPickLotId, - newInventoryLotNo: scanned.lotNo, - newStockInLineId: Number(scanned.stockInLineId ?? 0), - }); - switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE"; - const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message); - if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) { - const msg = (res.message as string) || t("Lot switch failed"); - setLotConfirmationError(msg); - setError(msg); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(msg); - }); - return; - } - if (nonBlockingReject && !switchedToUnavailable) { - const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick."); - setMessage(warnMsg); - } - } - - if (!switchedToUnavailable) { - const res = await workbenchScanPick({ - stockOutLineId: expected.row.stockOutLineId, - lotNo: scanned.lotNo, - ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 - ? { stockInLineId: Number(scanned.stockInLineId) } - : {}), - ...workbenchScanPickQtyFromLot(expected.row), - userId, + const res = await workbenchScanPick({ + stockOutLineId: expected.row.stockOutLineId, + lotNo: scanned.lotNo, + ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 + ? { stockInLineId: Number(scanned.stockInLineId) } + : {}), + ...workbenchScanPickQtyFromLot(expected.row), + userId, + }); + if (res.code !== "SUCCESS") { + const msg = (res.message as string) || t("Workbench scan-pick failed."); + setLotConfirmationError(msg); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); }); - if (res.code !== "SUCCESS") { - const msg = (res.message as string) || t("Workbench scan-pick failed."); - setLotConfirmationError(msg); - setError(msg); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(msg); - }); - return; - } + return; } setMessage(t("Scan pick success")); startTransition(() => { diff --git a/src/components/ProductionProcess/JobOrderOpsTable.tsx b/src/components/ProductionProcess/JobOrderOpsTable.tsx new file mode 100644 index 0000000..bb27757 --- /dev/null +++ b/src/components/ProductionProcess/JobOrderOpsTable.tsx @@ -0,0 +1,750 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Tab, + Tabs, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useSession } from "next-auth/react"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import dayjs, { Dayjs } from "dayjs"; +import { SessionWithTokens } from "@/config/authConfig"; +import { AUTH } from "@/authorities"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { + AllJoborderProductProcessInfoResponse, + completeProductProcessLine, + fetchJoborderProductProcessesPage, + fetchJos, + fetchProductProcessesByJobOrderId, + setJobOrderHidden, +} from "@/app/api/jo/actions"; +import { JobOrder } from "@/app/api/jo"; +import QcStockInModal from "@/components/Qc/QcStockInModal"; +import { StockInLineInput } from "@/app/api/stockIn"; +import type { PrinterCombo } from "@/app/api/settings/printer"; + +type PrimaryTab = + | "all" + | "pending" + | "processing" + | "pending_qc" + | "putawayed" + | "issue"; +type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started"; +type ProcessingSubTab = "all" | "picked_started" | "not_picked_started"; +type IssueSubTab = "stop" | "cancel"; + +type OpsRow = { + key: string; + jobOrderId: number; + jobOrderCode: string; + itemCode: string; + itemName: string; + requiredQty: number; + uom: string; + productionDate: string; + statusLabel: string; + pickProcessBucket?: string | null; + isPaused: boolean; + isCancelled: boolean; + /** pending_qc | putawayed | production | cancelled */ + rowKind: "production" | "pending_qc" | "putawayed" | "cancelled"; + stockInLineId?: number | null; + /** Present for production rows; used by complete-JO. */ + sourceProcess?: AllJoborderProductProcessInfoResponse; +}; + +/** Cards / page size for ops table fetch. */ +const FETCH_SIZE = 200; + +function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { + return (p.lines ?? []).some( + (l) => String(l.status ?? "").trim().toLowerCase() === "paused", + ); +} + +function toProductionRow( + p: AllJoborderProductProcessInfoResponse, + rowKind: OpsRow["rowKind"] = "production", +): OpsRow | null { + if (!p.jobOrderId) return null; + return { + key: `pp-${rowKind}-${p.jobOrderId}-${p.id}`, + jobOrderId: p.jobOrderId, + jobOrderCode: p.jobOrderCode || "-", + itemCode: p.itemCode || "-", + itemName: p.itemName || "-", + requiredQty: p.requiredQty ?? 0, + uom: p.uom || "", + productionDate: p.date || "", + statusLabel: + rowKind === "pending_qc" + ? "pending_qc" + : rowKind === "putawayed" + ? "putawayed" + : p.pickProcessBucket || p.status || "-", + pickProcessBucket: p.pickProcessBucket, + isPaused: isPausedProcess(p), + isCancelled: false, + rowKind, + stockInLineId: p.stockInLineId ?? null, + sourceProcess: p, + }; +} + +function toCancelledRow(jo: JobOrder): OpsRow { + const planStart = Array.isArray(jo.planStart) + ? dayjs( + new Date( + jo.planStart[0], + (jo.planStart[1] ?? 1) - 1, + jo.planStart[2] ?? 1, + ), + ).format("YYYY-MM-DD") + : jo.planStart + ? dayjs(jo.planStart as unknown as string).format("YYYY-MM-DD") + : ""; + return { + key: `jo-${jo.id}`, + jobOrderId: jo.id, + jobOrderCode: jo.code || "-", + itemCode: jo.item?.code || "-", + itemName: jo.itemName || jo.item?.name || "-", + requiredQty: jo.reqQty ?? 0, + uom: "", + productionDate: planStart, + statusLabel: "cancelled", + pickProcessBucket: null, + isPaused: false, + isCancelled: true, + rowKind: "cancelled", + stockInLineId: null, + }; +} + +/** Deduplicate by jobOrderId (prefer paused / higher priority). */ +function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] { + const map = new Map(); + for (const row of rows) { + const existing = map.get(row.jobOrderId); + if (!existing) { + map.set(row.jobOrderId, row); + continue; + } + if (!existing.isPaused && row.isPaused) { + map.set(row.jobOrderId, row); + } + } + return Array.from(map.values()); +} + +interface JobOrderOpsTableProps { + onSelectProcess?: (jobOrderId: number) => void; + printerCombo?: PrinterCombo[]; +} + +const JobOrderOpsTable: React.FC = ({ + onSelectProcess, + printerCombo = [], +}) => { + const { t } = useTranslation(["productionProcess", "common"]); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const sessionToken = session as SessionWithTokens | null; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN); + + const [queryDate, setQueryDate] = useState(() => dayjs()); + const [primaryTab, setPrimaryTab] = useState("all"); + const [pendingSub, setPendingSub] = useState("all"); + const [processingSub, setProcessingSub] = useState("all"); + const [issueSub, setIssueSub] = useState("stop"); + + const [productionRows, setProductionRows] = useState([]); + const [pendingQcRows, setPendingQcRows] = useState([]); + const [putawayedRows, setPutawayedRows] = useState([]); + const [cancelledRows, setCancelledRows] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmMessage, setConfirmMessage] = useState(""); + const [confirmLoading, setConfirmLoading] = useState(false); + const [pendingAction, setPendingAction] = useState Promise)>(null); + const actionInFlightRef = useRef(false); + const [rowBusyIds, setRowBusyIds] = useState>(new Set()); + const [qcModalOpen, setQcModalOpen] = useState(false); + const [qcModalInfo, setQcModalInfo] = useState(); + + const loadProduction = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + // lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend) + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + lookbackDays: 0, + bucket: "all", + qcReady: false, + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "production")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setProductionRows(dedupeByJobOrder(rows)); + }, [queryDate]); + + const loadPendingQc = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + lookbackDays: 0, + qcReady: true, + includePutaway: true, + putawayStatus: "notCompleted", + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "pending_qc")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setPendingQcRows(dedupeByJobOrder(rows)); + } catch (e) { + console.error("loadPendingQc failed", e); + setPendingQcRows([]); + } + }, [queryDate]); + + const loadPutawayed = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + qcReady: true, + includePutaway: true, + putawayStatus: "completed", + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "putawayed")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setPutawayedRows(dedupeByJobOrder(rows)); + } catch (e) { + console.error("loadPutawayed failed", e); + setPutawayedRows([]); + } + }, [queryDate]); + + const loadCancelled = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const res = await fetchJos({ + code: "", + planStart: dayStr, + planStartTo: dayStr, + joSearchStatus: "cancel", + pageNum: 0, + pageSize: FETCH_SIZE, + }); + setCancelledRows((res?.records ?? []).map(toCancelledRow)); + } catch (e) { + console.error("loadCancelled failed", e); + setCancelledRows([]); + } + }, [queryDate]); + + const loadData = useCallback(async () => { + setLoading(true); + const results = await Promise.allSettled([ + loadProduction(), + loadPendingQc(), + loadPutawayed(), + loadCancelled(), + ]); + if (results[0].status === "rejected") { + console.error(results[0].reason); + setProductionRows([]); + } + setLoading(false); + }, [loadProduction, loadPendingQc, loadPutawayed, loadCancelled]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + setPage(0); + }, [primaryTab, pendingSub, processingSub, issueSub, queryDate]); + + const filteredRows = useMemo(() => { + if (primaryTab === "pending_qc") return pendingQcRows; + if (primaryTab === "putawayed") return putawayedRows; + if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; + + let list = productionRows; + if (primaryTab === "pending") { + list = list.filter((r) => { + const b = r.pickProcessBucket; + if (pendingSub === "picked_not_started") return b === "picked_not_started"; + if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started"; + return b === "picked_not_started" || b === "not_picked_not_started"; + }); + } else if (primaryTab === "processing") { + list = list.filter((r) => { + const b = r.pickProcessBucket; + if (processingSub === "picked_started") return b === "picked_started"; + if (processingSub === "not_picked_started") return b === "not_picked_started"; + return b === "picked_started" || b === "not_picked_started"; + }); + } else if (primaryTab === "issue" && issueSub === "stop") { + list = list.filter((r) => r.isPaused); + } + return list; + }, [ + primaryTab, + pendingSub, + processingSub, + issueSub, + productionRows, + pendingQcRows, + putawayedRows, + cancelledRows, + ]); + + const counts = useMemo(() => { + const pending = productionRows.filter( + (r) => + r.pickProcessBucket === "picked_not_started" || + r.pickProcessBucket === "not_picked_not_started", + ).length; + const processing = productionRows.filter( + (r) => + r.pickProcessBucket === "picked_started" || + r.pickProcessBucket === "not_picked_started", + ).length; + const stop = productionRows.filter((r) => r.isPaused).length; + return { + all: productionRows.length, + pending, + processing, + pendingQc: pendingQcRows.length, + putawayed: putawayedRows.length, + stop, + cancel: cancelledRows.length, + }; + }, [productionRows, pendingQcRows, putawayedRows, cancelledRows]); + + const paginatedRows = useMemo(() => { + const start = page * pageSize; + return filteredRows.slice(start, start + pageSize); + }, [filteredRows, page, pageSize]); + + const openConfirm = useCallback((message: string, action: () => Promise) => { + setConfirmMessage(message); + setPendingAction(() => action); + setConfirmOpen(true); + }, []); + + const closeConfirm = useCallback(() => { + if (confirmLoading) return; + setConfirmOpen(false); + setPendingAction(null); + setConfirmMessage(""); + }, [confirmLoading]); + + const markBusy = (id: number, busy: boolean) => { + setRowBusyIds((prev) => { + const next = new Set(prev); + if (busy) next.add(id); + else next.delete(id); + return next; + }); + }; + + const handleOpenQcModal = useCallback( + (row: OpsRow) => { + if (!row.stockInLineId) { + alert(t("Invalid Stock In Line Id")); + return; + } + setQcModalInfo({ id: row.stockInLineId }); + setQcModalOpen(true); + }, + [t], + ); + + const handleComplete = useCallback( + (row: OpsRow) => { + if (!canManage || row.isCancelled) return; + openConfirm(t("Confirm to update this Job Order?"), async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + markBusy(row.jobOrderId, true); + try { + const processes = await fetchProductProcessesByJobOrderId(row.jobOrderId); + const lineIds = (processes ?? []) + .flatMap((p) => (p as { productProcessLines?: { id?: number }[] }).productProcessLines ?? []) + .map((l) => l.id) + .filter((id): id is number => !!id); + for (const lineId of lineIds) { + try { + await completeProductProcessLine(lineId); + } catch (e) { + console.error("completeProductProcessLine failed", lineId, e); + } + } + await loadData(); + } finally { + markBusy(row.jobOrderId, false); + actionInFlightRef.current = false; + } + }); + }, + [canManage, openConfirm, t, loadData], + ); + + const handleCancel = useCallback( + (row: OpsRow) => { + if (!canManage || row.isCancelled) return; + openConfirm(t("Cancel job order confirm message"), async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + markBusy(row.jobOrderId, true); + try { + await setJobOrderHidden(row.jobOrderId, true); + await loadData(); + } finally { + markBusy(row.jobOrderId, false); + actionInFlightRef.current = false; + } + }); + }, + [canManage, openConfirm, t, loadData], + ); + + const onConfirm = useCallback(async () => { + if (!pendingAction) return; + setConfirmLoading(true); + try { + await pendingAction(); + } catch (e) { + console.error(e); + } finally { + setConfirmLoading(false); + setConfirmOpen(false); + setPendingAction(null); + setConfirmMessage(""); + } + }, [pendingAction]); + + const bucketLabel = (row: OpsRow) => { + if (row.rowKind === "pending_qc") return t("Waiting QC Put Away"); + if (row.rowKind === "putawayed") return t("Put Awayed"); + if (row.isCancelled) return t("Cancelled"); + switch (row.pickProcessBucket) { + case "not_picked_not_started": + case "not_picked_started": + return t("Not picked"); + case "picked_not_started": + case "picked_started": + return t("Picked"); + default: + return row.statusLabel || "-"; + } + }; + + const showManageActions = + primaryTab !== "pending_qc" && + primaryTab !== "putawayed" && + !(primaryTab === "issue" && issueSub === "cancel"); + + return ( + + + + + {t("Job Order Ops Table")} + + + + v && setQueryDate(v)} + slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} + /> + + + + setPrimaryTab(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} + > + + + + + + + + + {primaryTab === "pending" && ( + setPendingSub(v)} + sx={{ mb: 2 }} + > + + + + + )} + + {primaryTab === "processing" && ( + setProcessingSub(v)} + sx={{ mb: 2 }} + > + + + + + )} + + {primaryTab === "issue" && ( + setIssueSub(v)} + sx={{ mb: 2 }} + > + + + + )} + + {loading ? ( + + + + ) : ( + <> + + + + + {t("Job Order")} + {t("Item")} + {t("Required Qty")} + {t("Production Date")} + {t("Status")} + + {t("Actions")} + + + + + {paginatedRows.length === 0 ? ( + + + {t("No data available")} + + + ) : ( + paginatedRows.map((row) => { + const busy = rowBusyIds.has(row.jobOrderId); + return ( + + {row.jobOrderCode} + + + {[row.itemCode, row.itemName].filter(Boolean).join(" ")} + + + + {row.requiredQty} + {row.uom ? ` ${row.uom}` : ""} + + + {row.productionDate && dayjs(row.productionDate).isValid() + ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) + : "-"} + + + + {row.isPaused && ( + + )} + + + + + + + {primaryTab === "pending_qc" && row.stockInLineId != null && ( + + )} + {primaryTab === "putawayed" && row.stockInLineId != null && ( + + )} + {showManageActions && !row.isCancelled && ( + + )} + {showManageActions && !row.isCancelled && ( + + )} + + + + ); + }) + )} + +
+
+ setPage(p)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[5, 10, 25, 50]} + labelRowsPerPage={t("Rows per page")} + /> + + )} +
+
+ + + {t("Confirm")} + + {confirmMessage} + + + + + + + + { + setQcModalOpen(false); + setQcModalInfo(undefined); + }} + inputDetail={qcModalInfo} + printerCombo={printerCombo} + warehouse={[]} + printSource="productionProcess" + uiMode="default" + /> +
+ ); +}; + +export default JobOrderOpsTable; diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 4859629..b763ff3 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useCallback, useEffect, useState, useMemo } from "react"; +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { Box, Button, @@ -23,7 +23,7 @@ import { } from "@mui/material"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useTranslation } from "react-i18next"; -import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; +import { fetchProductProcessesByJobOrderId , setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; import ProductionProcessDetail from "./ProductionProcessDetail"; import { BomCombo } from "@/app/api/bom"; import { fetchBomCombo } from "@/app/api/bom/index"; @@ -36,7 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; -import { releaseJo, startJo } from "@/app/api/jo/actions"; +import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; import ProcessSummaryHeader from "./ProcessSummaryHeader"; import EditIcon from "@mui/icons-material/Edit"; @@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps { initialTabIndex?: number; } +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, @@ -276,25 +277,13 @@ const isPutAwayed = useMemo( () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", [processData?.jobOrderStatus] ); -const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); -const [deleteLoading, setDeleteLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false); - -const handleConfirmDeleteJobOrder = useCallback(async () => { - setDeleteLoading(true); - try { - const response = await deleteJobOrder(jobOrderId); - if (response) { - setDeleteConfirmOpen(false); - onBack(); - } - } finally { - setDeleteLoading(false); - } -}, [jobOrderId, onBack]); +const cancelInFlightRef = useRef(false); const handleConfirmCancelJobOrder = useCallback(async () => { + if (cancelInFlightRef.current) return; + cancelInFlightRef.current = true; setCancelLoading(true); try { await setJobOrderHidden(jobOrderId, true); @@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => { onBack(); } finally { setCancelLoading(false); + cancelInFlightRef.current = false; } }, [jobOrderId, onBack]); -const handleRelease = useCallback(async ( jobOrderId: number) => { - // TODO: 替换为实际的 release 调用 - console.log("Release clicked for jobOrderId:", jobOrderId); - const response = await releaseJo({ id: jobOrderId }) - if (response) { - //setProcessData(response.entity); - await fetchData(); +const releaseInFlightRef = useRef(false); +const [isReleasing, setIsReleasing] = useState(false); + +const handleRelease = useCallback(async (jobOrderId: number) => { + if (releaseInFlightRef.current) return; + releaseInFlightRef.current = true; + setIsReleasing(true); + try { + // Workbench no-hold release: defer SPL/SOL/hold until first pick assign + const response = await releaseJoForWorkbench({ id: jobOrderId }); + if (response) { + await fetchData(); + } + } finally { + setIsReleasing(false); + releaseInFlightRef.current = false; } -}, [jobOrderId]); +}, [fetchData]); const handleTabChange = useCallback>( (_e, newValue) => { setTabIndex(newValue); @@ -722,21 +721,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { {t("Lines with insufficient stock: ")}{stockCounts.insufficient} - {fromJosave && jobOrderPlanning && ( - - )} - {fromJosave && !jobOrderPlanning && ( + {fromJosave && ( @@ -746,8 +736,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { variant="contained" color="primary" onClick={() => handleRelease(jobOrderId)} - //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} - disabled={processData?.jobOrderStatus !== "planning"} + disabled={processData?.jobOrderStatus !== "planning" || isReleasing} + startIcon={isReleasing ? : undefined} > {t("Release")} @@ -984,19 +974,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { - !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> - {t("Confirm delete job order")} - - {t("Delete job order confirm message")} - - - - - - - !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> {t("Confirm cancel job order")} diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index f559001..7ec8e83 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -1,16 +1,13 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Card, - CardContent, - CardActions, Stack, Typography, Chip, CircularProgress, - TablePagination, Grid, FormControl, InputLabel, @@ -23,7 +20,15 @@ import { DialogTitle, DialogContent, DialogActions, + Tabs, + Tab, + Badge, + Tooltip, + IconButton, + Avatar, } from "@mui/material"; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; import QcStockInModal from "../Qc/QcStockInModal"; @@ -41,11 +46,19 @@ import { fetchProductProcessesByJobOrderId, completeProductProcessLine, assignJobOrderPickOrder, - fetchJoborderProductProcessesPage + fetchJoborderProductProcessesPage, + JobOrderProductProcessBucketCounts, } from "@/app/api/jo/actions"; import { StockInLineInput } from "@/app/api/stockIn"; import { PrinterCombo } from "@/app/api/settings/printer"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; +export type ProductionProcessListTab = + | "needs_action" + | "pending" + | "processing" + | "pending_qc" + | "putawayed"; + export type ProductionProcessListPersistedState = { date: string; itemCode: string | null; @@ -53,13 +66,20 @@ export type ProductionProcessListPersistedState = { filter: "all" | "drink" | "Powder_Mixture" | "other"; page: number; selectedItemCodes: string[]; + /** + * Unified list tabs: + * needs_action (= pending+processing) | pending | processing | pending_qc | putawayed + * Legacy: all → needs_action; fine pick buckets remapped to pending/processing. + */ + pickBucket: ProductionProcessListTab | string; }; interface ProductProcessListProps { onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void; onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void; printerCombo: PrinterCombo[]; - qcReady: boolean; + /** @deprecated Derived from pickBucket when unified tabs are used; kept for compatibility. */ + qcReady?: boolean; includePutaway?: boolean | null; /** all | completed | notCompleted */ putawayStatus?: string | null; @@ -71,7 +91,28 @@ interface ProductProcessListProps { } export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; -const PAGE_SIZE = 50; +/** Cards per visible page: 2 rows × 3 columns. */ +const CARDS_PER_PAGE = 6; +/** Fetch once; client slides pages of CARDS_PER_PAGE (no refetch on page change). */ +const FETCH_SIZE = 200; +/** Include unfinished from (searchDate - LOOKBACK_DAYS) .. searchDate; picked_not_started capped at search date on backend. */ +const PRODUCTION_LOOKBACK_DAYS = 4; + +const PENDING_FINE_BUCKETS = new Set([ + "not_picked_not_started", + "picked_not_started", +]); +const PROCESSING_FINE_BUCKETS = new Set([ + "picked_started", + "not_picked_started", +]); + +const EMPTY_BUCKET_COUNTS: JobOrderProductProcessBucketCounts = { + notPickedNotStarted: 0, + pickedNotStarted: 0, + pickedStarted: 0, + notPickedStarted: 0, +}; /** 預設依 JobOrder.planStart 搜索:今天往前 3 天~往後 3 天(含當日) */ function defaultPlanStartRange() { @@ -89,16 +130,26 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr filter: "all", page: 0, selectedItemCodes: [], + pickBucket: "needs_action", }; } +function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab { + const v = (raw || "needs_action").trim(); + if (v === "pending" || v === "processing" || v === "pending_qc" || v === "putawayed" || v === "needs_action") { + return v; + } + if (v === "all") return "needs_action"; + if (v === "not_picked_not_started" || v === "picked_not_started") return "pending"; + if (v === "picked_started" || v === "not_picked_started") return "processing"; + return "needs_action"; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, onSelectMatchingStock, - qcReady, - includePutaway, - putawayStatus, disableDateFilter = false, listPersistedState, onListPersistedStateChange, @@ -108,6 +159,11 @@ const ProductProcessList: React.FC = ({ const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); const [processes, setProcesses] = useState([]); + const [bucketCounts, setBucketCounts] = + useState(EMPTY_BUCKET_COUNTS); + const [pendingQcCount, setPendingQcCount] = useState(0); + const [putawayedCount, setPutawayedCount] = useState(0); + const [carriedOverCount, setCarriedOverCount] = useState(0); const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -115,7 +171,21 @@ const ProductProcessList: React.FC = ({ // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; - const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); + + const listTab = normalizeListTab(listPersistedState.pickBucket); + const isProductionTab = + listTab === "needs_action" || listTab === "pending" || listTab === "processing"; + const qcReady = listTab === "pending_qc" || listTab === "putawayed"; + const putawayStatus = + listTab === "putawayed" + ? "completed" + : listTab === "pending_qc" + ? "notCompleted" + : null; + const includePutaway = qcReady ? true : null; + /** Production unfinished tabs: carry-over + pick buckets. Pending QC: carry-over only. */ + const enableCarryOver = + !disableDateFilter && (isProductionTab || listTab === "pending_qc"); const appliedSearch = useMemo( () => ({ @@ -133,6 +203,11 @@ const ProductProcessList: React.FC = ({ const page = listPersistedState.page; const selectedItemCodes = listPersistedState.selectedItemCodes; + const searchDay = useMemo( + () => (appliedSearch.date ? dayjs(appliedSearch.date).startOf("day") : null), + [appliedSearch.date], + ); + const [totalJobOrders, setTotalJobOrders] = useState(0); // Generic confirm dialog for actions (update job order / etc.) @@ -270,6 +345,7 @@ const ProductProcessList: React.FC = ({ jobOrderCode: null, selectedItemCodes: [], page: 0, + pickBucket: "needs_action", })); }, [disableDateFilter, onListPersistedStateChange]); @@ -277,33 +353,265 @@ const ProductProcessList: React.FC = ({ setLoading(true); try { const typeParam = filter === "all" ? undefined : filter; - + // Production tabs share one fetch (bucket=all); pending/processing filter client-side. const data = await fetchJoborderProductProcessesPage({ date: disableDateFilter ? undefined : appliedSearch.date, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, qcReady, - includePutaway: includePutaway ?? (qcReady ? true : null), + includePutaway, putawayStatus, type: typeParam, - page, - size: PAGE_SIZE, + lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, + bucket: isProductionTab ? "all" : undefined, + page: 0, + size: FETCH_SIZE, }); setProcesses(data?.content || []); setTotalJobOrders(data?.totalJobOrders || 0); + if (isProductionTab && data?.bucketCounts) { + setBucketCounts(data.bucketCounts); + } + if (qcReady && putawayStatus === "notCompleted") { + setPendingQcCount(data?.totalJobOrders || 0); + } + if (qcReady && putawayStatus === "completed") { + setPutawayedCount(data?.totalJobOrders || 0); + } + setCarriedOverCount(data?.carriedOverCount ?? 0); } catch (e) { console.error(e); setProcesses([]); setTotalJobOrders(0); + if (isProductionTab) setBucketCounts(EMPTY_BUCKET_COUNTS); + setCarriedOverCount(0); } finally { setLoading(false); } - }, [appliedSearch, disableDateFilter, filter, qcReady, includePutaway, putawayStatus, page]); + }, [ + appliedSearch, + disableDateFilter, + filter, + qcReady, + includePutaway, + putawayStatus, + enableCarryOver, + isProductionTab, + ]); useEffect(() => { fetchProcesses(); }, [fetchProcesses]); + + /** Keep production + QC tab badges fresh even when not on that tab. */ + useEffect(() => { + let cancelled = false; + const typeParam = filter === "all" ? undefined : filter; + const base = { + date: disableDateFilter ? undefined : appliedSearch.date, + itemCode: appliedSearch.itemCode, + jobOrderCode: appliedSearch.jobOrderCode, + type: typeParam, + page: 0, + size: 1, + }; + + (async () => { + try { + const [prod, pendingQc, putawayed] = await Promise.all([ + fetchJoborderProductProcessesPage({ + ...base, + qcReady: false, + lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, + bucket: "all", + }), + fetchJoborderProductProcessesPage({ + ...base, + qcReady: true, + includePutaway: true, + putawayStatus: "notCompleted", + lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, + }), + fetchJoborderProductProcessesPage({ + ...base, + qcReady: true, + includePutaway: true, + putawayStatus: "completed", + }), + ]); + if (cancelled) return; + if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); + setPendingQcCount(pendingQc?.totalJobOrders || 0); + setPutawayedCount(putawayed?.totalJobOrders || 0); + } catch (e) { + console.error(e); + } + })(); + + return () => { + cancelled = true; + }; + }, [appliedSearch, disableDateFilter, filter]); + + const handleListTabChange = useCallback( + (_: React.SyntheticEvent, value: string) => { + const next = normalizeListTab(value); + onListPersistedStateChange((prev) => ({ + ...prev, + pickBucket: next, + page: 0, + })); + }, + [onListPersistedStateChange], + ); + + const pendingCount = + bucketCounts.notPickedNotStarted + bucketCounts.pickedNotStarted; + const processingCount = + bucketCounts.pickedStarted + bucketCounts.notPickedStarted; + const needsActionCount = pendingCount + processingCount; + + const filteredProcesses = useMemo(() => { + let list = processes; + if (listTab === "pending") { + list = list.filter((p) => + PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), + ); + } else if (listTab === "processing") { + list = list.filter((p) => + PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), + ); + } + if (selectedItemCodes.length === 0) return list; + return list.filter((p) => selectedItemCodes.includes(p.itemCode)); + }, [processes, selectedItemCodes, listTab]); + + const displayTotalJobOrders = isProductionTab + ? listTab === "pending" + ? pendingCount + : listTab === "processing" + ? processingCount + : totalJobOrders || needsActionCount + : totalJobOrders; + + const displayCarriedOverCount = useMemo(() => { + if (!enableCarryOver || !searchDay) return 0; + if (!isProductionTab || listTab === "needs_action") return carriedOverCount; + return filteredProcesses.filter((p) => { + if (!p.date || !dayjs(p.date).isValid()) return false; + return dayjs(p.date).startOf("day").isBefore(searchDay); + }).length; + }, [ + enableCarryOver, + searchDay, + isProductionTab, + listTab, + carriedOverCount, + filteredProcesses, + ]); + + const pageChunks = useMemo(() => { + const chunks: AllJoborderProductProcessInfoResponse[][] = []; + for (let i = 0; i < filteredProcesses.length; i += CARDS_PER_PAGE) { + chunks.push(filteredProcesses.slice(i, i + CARDS_PER_PAGE)); + } + return chunks.length > 0 ? chunks : [[]]; + }, [filteredProcesses]); + + const totalPages = pageChunks.length; + const safePage = Math.min(page, Math.max(0, totalPages - 1)); + + const scrollerRef = useRef(null); + const scrollSyncLockRef = useRef(false); + + const scrollToPage = useCallback( + (targetPage: number, behavior: ScrollBehavior = "smooth") => { + const el = scrollerRef.current; + if (!el) return; + const clamped = Math.max(0, Math.min(targetPage, totalPages - 1)); + scrollSyncLockRef.current = true; + el.scrollTo({ left: clamped * el.clientWidth, behavior }); + onListPersistedStateChange((prev) => + prev.page === clamped ? prev : { ...prev, page: clamped }, + ); + window.setTimeout(() => { + scrollSyncLockRef.current = false; + }, behavior === "smooth" ? 450 : 50); + }, + [totalPages, onListPersistedStateChange], + ); + + const goPrevPage = useCallback(() => { + if (safePage <= 0) return; + scrollToPage(safePage - 1); + }, [safePage, scrollToPage]); + + const goNextPage = useCallback(() => { + if (safePage + 1 >= totalPages) return; + scrollToPage(safePage + 1); + }, [safePage, totalPages, scrollToPage]); + + const handleScrollerScroll = useCallback(() => { + if (scrollSyncLockRef.current) return; + const el = scrollerRef.current; + if (!el || el.clientWidth <= 0) return; + const nextPage = Math.round(el.scrollLeft / el.clientWidth); + const clamped = Math.max(0, Math.min(nextPage, totalPages - 1)); + if (clamped !== page) { + onListPersistedStateChange((prev) => + prev.page === clamped ? prev : { ...prev, page: clamped }, + ); + } + }, [page, totalPages, onListPersistedStateChange]); + + // After data reload, jump to persisted page without animation. + useEffect(() => { + const el = scrollerRef.current; + if (!el || loading) return; + const clamped = Math.min(page, Math.max(0, totalPages - 1)); + scrollSyncLockRef.current = true; + el.scrollTo({ left: clamped * el.clientWidth, behavior: "auto" }); + window.setTimeout(() => { + scrollSyncLockRef.current = false; + }, 50); + }, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps + + const renderBucketTabLabel = useCallback( + (labelKey: string, count: number) => ( + 0 ? t(labelKey) + `: ${count}` : t(labelKey)}> + + 99 ? "99+" : count} + invisible={count === 0} + sx={{ + "& .MuiBadge-badge": { + fontWeight: 800, + fontSize: "0.7rem", + minWidth: 18, + height: 18, + lineHeight: "18px", + px: 0.5, + right: -8, + top: 2, + }, + }} + > + 0 ? 1 : 0 }} + > + {t(labelKey)} + + + + + ), + [t], + ); const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { if (!canManageUpdateJo) return; if (!process.jobOrderId) { @@ -383,11 +691,6 @@ const ProductProcessList: React.FC = ({ [processes], ); - const paged = useMemo(() => { - if (selectedItemCodes.length === 0) return processes; - return processes.filter((p) => selectedItemCodes.includes(p.itemCode)); - }, [processes, selectedItemCodes]); - /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ const searchCriteria: Criterion[] = useMemo(() => { const base: Criterion[] = [ @@ -489,175 +792,424 @@ const ProductProcessList: React.FC = ({ {" | "} )} - {t("Total job orders")}: {totalJobOrders} - {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${paged.length}` : ""} + {t("Total job orders")}: {displayTotalJobOrders} + {enableCarryOver && displayCarriedOverCount > 0 + ? ` | ${t("Including carried over")}: ${displayCarriedOverCount}` + : ""} + {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""}
- - {paged.map((process) => { - const status = String(process.status || ""); - const statusLower = status.toLowerCase(); - const displayStatus = statusLower === "in_progress" ? "processing" : status; - const statusColor = - statusLower === "completed" - ? "success" - : statusLower === "in_progress" || statusLower === "processing" - ? "primary" - : "default"; - - const finishedCount = - (process.lines || []).filter( - (l) => String(l.status ?? "").trim().toLowerCase() === "completed" || String(l.status ?? "").trim().toLowerCase() === "pass" - ).length; - - const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0; - const linesWithStatus = (process.lines || []).filter( - (l) => String(l.status ?? "").trim() !== "" - ); - - const dateDisplay = process.date - ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT) - : "-"; - const jobOrderCode = - (process as any).jobOrderCode ?? - (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A"); - const inProgressLines = (process.lines || []) - .filter(l => String(l.status ?? "").trim() !== "") - .filter(l => String(l.status).toLowerCase() === "in_progress"); - - const canQc = - process.jobOrderId != null && - process.stockInLineId != null && - jobOrderQcReadyById.get(process.jobOrderId) === true; - - return ( - - - - - - - {t("Job Order")}: {jobOrderCode} - - - - - - - - {t("Lot No")}: {process.lotNo ?? "-"} - - - {/* {t("Item Name")}: */} - {process.itemCode} {process.itemName} - {process.bomDescription ? ` (${t(process.bomDescription as string)})` : ""} - - - {t("Production Priority")}: {process.productionPriority} - - - {t("Required Qty")}: {process.requiredQty} ({process.uom}) - - - {t("Production date")}: {process.date ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT) : "-"} - - - {t("Assume Time Need")}: {process.timeNeedToComplete} {t("minutes")} - - {statusLower !== "pending" && linesWithStatus.length > 0 && ( - - - {t("Finished lines")}: {finishedCount} / {totalCount} - - - {inProgressLines.length > 0 && ( - - {inProgressLines.map(line => ( - - {t("Operator")}: {line.operatorName || "-"}
- {t("Equipment")}: {line.equipmentName || "-"} + + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + + + { + if (e.key === "ArrowLeft") goPrevPage(); + if (e.key === "ArrowRight") goNextPage(); + }} + > + + + + + + {pageChunks.map((chunk, pageIndex) => ( + + + {chunk.map((process) => { + const status = String(process.status || ""); + const statusLower = status.toLowerCase(); + const displayStatus = + statusLower === "in_progress" ? "processing" : status; + const chipLabel = qcReady + ? putawayStatus === "completed" + ? t("Put Awayed") + : t("Waiting QC Put Away") + : t(displayStatus); + const statusColor = qcReady + ? putawayStatus === "completed" + ? "success" + : "warning" + : statusLower === "completed" + ? "success" + : statusLower === "in_progress" || + statusLower === "processing" + ? "primary" + : "default"; + + const jobOrderCode = + (process as any).jobOrderCode ?? + (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A"); + + const canQc = + process.jobOrderId != null && + process.stockInLineId != null && + jobOrderQcReadyById.get(process.jobOrderId) === true; + + const joDay = process.date + ? dayjs(process.date).startOf("day") + : null; + const isCarriedOver = Boolean( + enableCarryOver && + searchDay?.isValid() && + joDay?.isValid() && + joDay.isBefore(searchDay), + ); + + const bomDescription = process.bomDescription + ? String(process.bomDescription).trim() + : ""; + const bomType = process.bomType + ? String(process.bomType).trim() + : ""; + + const chipSx = { + flexShrink: 0, + height: 28, + borderRadius: "14px", + "& .MuiChip-label": { + typography: "body2", + px: 1.25, + lineHeight: 1.2, + }, + } as const; + + return ( + + + + + + {[process.itemCode, process.itemName].filter(Boolean).join(" ") || "-"} - ))} - - )} - - )} - {statusLower == "pending" && ( - - - {t("Pending")} - - - - {""} - + {isCarriedOver ? ( + + + ! + + + ) : null} +
+ + + + + {process.productionPriority ?? "-"} + + + + {bomDescription ? ( + + ) : null} + {bomType ? ( + + ) : null} + + + + + + {t("Required Qty")}: {process.requiredQty} ( + {process.uom}) + + + {t("Production date")}:{" "} + {process.date + ? dayjs(process.date as any).format( + OUTPUT_DATE_FORMAT, + ) + : "-"} + + + {t("Assume Time Need")}:{" "} + {process.timeNeedToComplete} {t("minutes")} + + + + + + + + {statusLower !== "completed" && ( + + )} + + {canQc && ( + + )} + + + + {jobOrderCode} + {" · "} + {t("Lot No")}: {process.lotNo ?? "-"} +
- - )} - - - - - - - - {statusLower !== "completed" && ( - - )} - - {canQc && ( - - )} - - - - - - ); - })} - + + + ); + })} + + + ))} + + + = totalPages || filteredProcesses.length === 0 + } + sx={{ alignSelf: "center" }} + > + + + = ({ - {totalJobOrders > 0 && ( - - onListPersistedStateChange((prev) => ({ ...prev, page: p })) - } - rowsPerPageOptions={[PAGE_SIZE]} - /> + {filteredProcesses.length > 0 && ( + + {safePage + 1} / {totalPages} + )} )} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index d060c7b..3bc4f4c 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -8,13 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal"; import ProductionProcessList, { createDefaultProductionProcessListPersistedState, } from "@/components/ProductionProcess/ProductionProcessList"; -import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; +import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; import type { PrinterCombo } from "@/app/api/settings/printer"; import { useTranslation } from "react-i18next"; @@ -26,25 +26,18 @@ const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ const ProductionProcessPage: React.FC = ({ printerCombo }) => { - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "productionProcess"]); const [selectedProcessId, setSelectedProcessId] = useState(null); const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ jobOrderId: number; productProcessId: number; pickOrderId: number; } | null>(null); + /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ const [tabIndex, setTabIndex] = useState(0); - /** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ const [productionListState, setProductionListState] = useState(() => ({ ...createDefaultProductionProcessListPersistedState(), - // date: "", })); - const [waitingPutawayListState, setWaitingPutawayListState] = useState( - createDefaultProductionProcessListPersistedState, - ); - const [putawayedListState, setPutawayedListState] = useState( - createDefaultProductionProcessListPersistedState, - ); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const searchParams = useSearchParams(); @@ -53,22 +46,18 @@ const ProductionProcessPage: React.FC = ({ printerCo const [linkQcOpen, setLinkQcOpen] = useState(false); const [linkQcSilId, setLinkQcSilId] = useState(null); - // Add printer selection state const [selectedPrinter, setSelectedPrinter] = useState( printerCombo && printerCombo.length > 0 ? printerCombo[0] : null ); - // 从 sessionStorage 恢复状态(仅在客户端) useEffect(() => { if (typeof window !== 'undefined') { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); - // 验证数据有效性 if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { setSelectedMatchingStock(parsed); - console.log(" Restored selectedMatchingStock from sessionStorage:", parsed); } } } catch (error) { @@ -78,19 +67,16 @@ const ProductionProcessPage: React.FC = ({ printerCo } }, []); - // 保存状态到 sessionStorage useEffect(() => { if (typeof window !== 'undefined') { if (selectedMatchingStock) { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); - console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock); } else { sessionStorage.removeItem(STORAGE_KEY); } } }, [selectedMatchingStock]); - // 处理返回列表时清除存储 const handleBackFromSecondScan = useCallback(() => { setSelectedMatchingStock(null); if (typeof window !== 'undefined') { @@ -104,7 +90,7 @@ const ProductionProcessPage: React.FC = ({ printerCo const openStockInLineIdQ = searchParams.get("openStockInLineId"); - /** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */ + /** Deep link: /productionProcess?openStockInLineId=… → list tab pending_qc + FG QC modal */ useEffect(() => { if (!openStockInLineIdQ) { setLinkQcOpen(false); @@ -115,7 +101,12 @@ const ProductionProcessPage: React.FC = ({ printerCo if (!Number.isFinite(id) || id <= 0) return; setSelectedProcessId(null); setSelectedMatchingStock(null); - setTabIndex(1); + setTabIndex(0); + setProductionListState((prev) => ({ + ...prev, + pickBucket: "pending_qc", + page: 0, + })); setLinkQcSilId(id); setLinkQcOpen(true); }, [openStockInLineIdQ]); @@ -129,6 +120,9 @@ const ProductionProcessPage: React.FC = ({ printerCo router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); }, [pathname, router, searchParams]); + const listTab = String(productionListState.pickBucket || "needs_action"); + const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; + if (selectedMatchingStock) { return ( = ({ printerCo return ( <> - {/* Header section with printer selection */} - {tabIndex === 1 && ( + {showPrinterBar && ( = ({ printerCo - - + @@ -215,9 +207,7 @@ const ProductionProcessPage: React.FC = ({ printerCo {tabIndex === 0 && ( { @@ -237,62 +227,24 @@ const ProductionProcessPage: React.FC = ({ printerCo )} {tabIndex === 1 && ( - { - const id = jobOrderId ?? null; - if (id !== null) { - setSelectedProcessId(id); - } - }} - onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { - setSelectedMatchingStock({ - jobOrderId: jobOrderId || 0, - productProcessId: productProcessId || 0, - pickOrderId: pickOrderId || 0, - }); + if (jobOrderId != null) setSelectedProcessId(jobOrderId); }} /> )} {tabIndex === 2 && ( - { - const id = jobOrderId ?? null; - if (id !== null) { - setSelectedProcessId(id); - } - }} - onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { - setSelectedMatchingStock({ - jobOrderId: jobOrderId || 0, - productProcessId: productProcessId || 0, - pickOrderId: pickOrderId || 0, - }); - }} - /> - )} - {tabIndex === 3 && ( )} - {tabIndex === 4 && ( + {tabIndex === 3 && ( )} - {tabIndex === 5 && ( + {tabIndex === 4 && ( )} - {tabIndex === 6 && ( + {tabIndex === 5 && ( )} @@ -310,4 +262,4 @@ const ProductionProcessPage: React.FC = ({ printerCo ); }; -export default ProductionProcessPage; \ No newline at end of file +export default ProductionProcessPage; diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index 6a20247..47dd05f 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -1,3 +1,4 @@ +/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; import { NEXT_PUBLIC_API_URL } from "@/config/api"; @@ -224,7 +225,7 @@ export const REPORTS: ReportDefinition[] = [ { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, - { label: "提料人 Handler", name: "handler", type: "select", required: false, + { label: "提料員 Handler", name: "handler", type: "select", required: false, multiple: true, dynamicOptions: true, dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`, @@ -336,4 +337,50 @@ export const REPORTS: ReportDefinition[] = [ }, ], }, + { + id: "rep-016", + title: "成品出倉揀貨合規報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-user-pick-audit`, + responseType: "excel", + fields: [ + { label: "日期 Date", name: "dateStart", type: "date", required: true }, + { + label: "提料人 Handler", + name: "handler", + type: "select", + required: false, + multiple: true, + dynamicOptions: true, + dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/do-user-pick-audit-handlers`, + options: [], + }, + { label: "提票號碼", name: "ticketNo", type: "text", required: false }, + { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + { + label: "樓層", + name: "storeId", + type: "select", + required: false, + options: [ + { label: "2F", value: "2F" }, + { label: "4F", value: "4F" }, + ], + }, + ], + }, + { + id: "rep-017", + title: "店鋪訂單補貨記錄", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment`, + responseType: "excel", + fields: [ + // { label: "補貨日期:由 Reorder Date Start", name: "reorderDateStart", type: "date", required: false }, + //{ label: "補貨日期:至 Reorder Date End", name: "reorderDateEnd", type: "date", required: false }, + { label: "店鋪訂單日期:由 Shop Order Date Start", name: "shopOrderDateStart", type: "date", required: false }, + { label: "店鋪訂單日期:至 Shop Order Date End", name: "shopOrderDateEnd", type: "date", required: false }, + //{ label: "送貨日期:由 Delivered Date Start", name: "deliveredDateStart", type: "date", required: false }, + //{ label: "送貨日期:至 Delivered Date End", name: "deliveredDateEnd", type: "date", required: false }, + { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, + ], + }, ] \ No newline at end of file diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index c0cac69..8d40ea8 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -62,7 +62,7 @@ "Not Started": "Not Started", "cancelled": "Cancelled", "in_progress": "In Progress", - "pending": "Pending", + "pending": "Awaiting production", "stopped": "Stopped", "Invalid Job Order Id": "Invalid Job Order Id", "Invalid Stock In Line Id": "Invalid Stock In Line Id", @@ -76,6 +76,20 @@ "Job Order Info": "Job Order Info", "Job Order No.": "Job Order No.", "Job Order and Product": "Job Order and Product", + "Issue": "Issue", + "Job Order Ops Table": "Job Order Ops Table", + "Pending (picked)": "Picked", + "Pending (not picked)": "Not picked", + "Processing (picked)": "Picked", + "Processing (not picked)": "Not picked", + "Picked": "Picked", + "Not picked": "Not picked", + "Stop (paused)": "Stop (paused)", + "Cancelled": "Cancelled", + "Reload data": "Reload data", + "Rows per page": "Rows per page", + "Processing...": "Processing...", + "Actions": "Actions", "Job Order Production Process": "Job Order Production Process", "Job Process Status Dashboard": "Job Process Status Dashboard", "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", @@ -198,6 +212,16 @@ "Total Time": "Total Time", "Total finished QC job orders": "Total finished QC job orders", "Total job orders": "Total job orders", + "Including carried over": "Including carried over", + "Carried over from past day": "Carried over from past day", + "All unfinished": "Needs action", + "Needs action": "Needs action", + "Not picked · Not started": "Not picked · Not started", + "Picked · Not started": "Picked · Not started", + "Picked · In production": "Picked · In production", + "Not picked · In production": "Not picked · In production", + "Powder_Mixture": "Powder Mixture", + "Processing": "Processing", "Total lines: ": "Total lines: ", "Type": "Type", "Unable to get user ID": "Unable to get user ID", @@ -212,6 +236,9 @@ "View Details": "View Details", "Wait Time": "Wait Time", "Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders", + "Waiting QC Put Away": "Waiting QC Put Away", + "Put Awayed": "Put Awayed", + "Put Away Detail": "Put Away Detail", "all": "All", "drink": "Drink", "id": "ID", diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json index 055f932..5c3d1ba 100644 --- a/src/i18n/zh/itemTracing.json +++ b/src/i18n/zh/itemTracing.json @@ -190,7 +190,7 @@ "jobOrder": "工單", "deliveryOrder": "送貨單", "deliveryNoteCode": "送貨單據號 (DN)", - "ticketNo": "票號", + "ticketNo": "提票號碼", "variance": "差異", "before": "帳面數量", "after": "核准數量", diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index d5e2e11..3f378f6 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -62,7 +62,7 @@ "Not Started": "未開始", "cancelled": "已取消", "in_progress": "進行中", - "pending": "待處理", + "pending": "待生產", "stopped": "已停止", "Invalid Job Order Id": "無效工單編號", "Invalid Stock In Line Id": "無效庫存行ID", @@ -76,6 +76,20 @@ "Job Order Info": "工單信息", "Job Order No.": "工單編號", "Job Order and Product": "工單及貨品", + "Issue": "異常", + "Job Order Ops Table": "查看工單流程情況", + "Pending (picked)": "已提料", + "Pending (not picked)": "未提料", + "Processing (picked)": "已提料", + "Processing (not picked)": "未提料", + "Picked": "已提料", + "Not picked": "未提料", + "Stop (paused)": "暫停中", + "Cancelled": "已取消", + "Reload data": "重新載入", + "Rows per page": "每頁列數", + "Processing...": "處理中...", + "Actions": "操作", "Job Order Production Process": "工單生產流程", "Job Process Status Dashboard": "儀表板 - 工單狀態", "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", @@ -198,6 +212,16 @@ "Total Time": "總時間", "Total finished QC job orders": "總完成QC工單數量", "Total job orders": "總工單數量", + "Including carried over": "含過去轉來", + "Carried over from past day": "過去轉來的工單", + "All unfinished": "需處理", + "Needs action": "需處理", + "Not picked · Not started": "未提料 · 未開工", + "Picked · Not started": "已提料 · 未開工", + "Picked · In production": "已提料 · 未完成生產", + "Not picked · In production": "未提料 · 未完成生產", + "Powder_Mixture": "箱料粉", + "Processing": "生產中", "Total lines: ": "總數量:", "Type": "類型", "Unable to get user ID": "無法獲取用戶ID", @@ -212,6 +236,9 @@ "View Details": "查看詳情", "Wait Time": "等待時間", "Waiting QC Put Away Job Orders": "待QC上架工單", + "Waiting QC Put Away": "待QC上架", + "Put Awayed": "已上架", + "Put Away Detail": "上架詳情", "all": "全部", "drink": "飲料", "id": "ID",