| @@ -0,0 +1,44 @@ | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import ItemTracing from "@/components/ItemTracing"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { Suspense } from "react"; | |||
| import ItemTracingLoading from "@/components/ItemTracing/ItemTracingLoading"; | |||
| export const metadata: Metadata = { | |||
| title: "Item Tracing", | |||
| }; | |||
| const canAccess = (abilities: string[]) => | |||
| abilities.includes(AUTH.ITEM_TRACING); | |||
| const ItemTracingPage: React.FC = async () => { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| if (!canAccess(abilities)) { | |||
| redirect("/dashboard"); | |||
| } | |||
| const { t } = await getServerI18n("itemTracing"); | |||
| return ( | |||
| <> | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("title")} | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["itemTracing", "navigation", "common"]}> | |||
| <Suspense fallback={<ItemTracingLoading />}> | |||
| <ItemTracing /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ItemTracingPage; | |||
| @@ -0,0 +1,24 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { ItemLotTraceParams, ItemLotTraceResponse } from "."; | |||
| import { parseItemLotTraceResponse } from "./schema"; | |||
| export const fetchItemLotTrace = async ( | |||
| params: ItemLotTraceParams, | |||
| ): Promise<ItemLotTraceResponse> => { | |||
| const query = convertObjToURLSearchParams(params as Record<string, unknown>); | |||
| const json = await serverFetchJson<unknown>( | |||
| `${BASE_API_URL}/inventoryLotLine/trace?${query}`, | |||
| { method: "GET" }, | |||
| ); | |||
| return parseItemLotTraceResponse(json) as unknown as ItemLotTraceResponse; | |||
| }; | |||
| /** | |||
| * Backend also exposes GET /inventoryLotLine/trace/location/{inventoryLotId} for | |||
| * lazy location blocks. The main /trace payload already includes locationBlocks, | |||
| * so the UI uses that eager path and does not call a separate location fetch. | |||
| */ | |||
| @@ -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; | |||
| } | |||
| @@ -0,0 +1,211 @@ | |||
| import { z } from "zod"; | |||
| const nullableStr = z.string().nullable().optional(); | |||
| const nullableNum = z.number().nullable().optional(); | |||
| const ItemLotTraceOriginSchema = z | |||
| .object({ | |||
| stockInLineId: z.number(), | |||
| type: z.string(), | |||
| refCode: z.string(), | |||
| refId: nullableNum, | |||
| supplierCode: z.string(), | |||
| supplierName: z.string(), | |||
| dnNo: z.string(), | |||
| receiptDate: nullableStr, | |||
| acceptedQty: z.number(), | |||
| status: z.string(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceQcResultSchema = z | |||
| .object({ | |||
| stockInLineId: z.number(), | |||
| qcPassed: z.boolean(), | |||
| failQty: z.number(), | |||
| acceptedQty: z.number(), | |||
| remarks: z.string(), | |||
| handledBy: z.string(), | |||
| created: nullableStr, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTracePutawayEventSchema = z | |||
| .object({ | |||
| inventoryLotLineId: z.number(), | |||
| stockInLineId: nullableNum, | |||
| warehouseCode: z.string(), | |||
| qty: z.number(), | |||
| handledBy: z.string(), | |||
| timestamp: nullableStr, | |||
| refType: z.string(), | |||
| refCode: z.string(), | |||
| refId: nullableNum, | |||
| status: z.string().optional(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceProductionStepSchema = z | |||
| .object({ | |||
| processLineId: z.number(), | |||
| stepName: z.string(), | |||
| description: z.string(), | |||
| equipmentCode: z.string(), | |||
| equipmentName: z.string(), | |||
| operatorName: z.string(), | |||
| startTime: nullableStr, | |||
| endTime: nullableStr, | |||
| outputQty: z.number(), | |||
| scrapQty: z.number(), | |||
| defectQty: z.number(), | |||
| status: z.string(), | |||
| seqNo: nullableNum, | |||
| bomProcessId: nullableNum, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceMaterialInputSchema: z.ZodType<Record<string, unknown>> = z.lazy(() => | |||
| z | |||
| .object({ | |||
| jobOrderCode: z.string(), | |||
| jobOrderId: nullableNum, | |||
| materialItemCode: z.string(), | |||
| materialItemName: z.string(), | |||
| materialLotNo: z.string(), | |||
| materialStockInLineId: nullableNum, | |||
| materialInventoryLotId: nullableNum, | |||
| materialQty: z.number(), | |||
| materialUom: z.string().optional(), | |||
| bomQtyPerUnit: nullableNum, | |||
| bomProcessId: nullableNum, | |||
| bomProcessSeqNo: nullableNum, | |||
| assignedStepName: z.string().optional(), | |||
| pickOrderCode: z.string(), | |||
| pickOrderId: nullableNum, | |||
| consoCode: z.string(), | |||
| pickedAt: nullableStr, | |||
| stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(), | |||
| purchaseEvents: z.array(z.record(z.unknown())).optional(), | |||
| qcResults: z.array(ItemLotTraceQcResultSchema), | |||
| putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(), | |||
| nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(), | |||
| productionSteps: z.array(ItemLotTraceProductionStepSchema).optional(), | |||
| }) | |||
| .passthrough(), | |||
| ); | |||
| const ItemLotTraceJoPreludeSchema: z.ZodType<Record<string, unknown>> = z.lazy(() => | |||
| z | |||
| .object({ | |||
| jobOrder: z | |||
| .object({ | |||
| jobOrderId: z.number(), | |||
| jobOrderCode: z.string(), | |||
| planStart: nullableStr, | |||
| createdAt: nullableStr.optional(), | |||
| reqQty: z.number(), | |||
| status: z.string(), | |||
| }) | |||
| .passthrough(), | |||
| pickOrders: z.array(z.record(z.unknown())), | |||
| materialInputs: z.array(ItemLotTraceMaterialInputSchema), | |||
| }) | |||
| .passthrough(), | |||
| ); | |||
| const ItemLotTraceGraphEdgeSchema = z | |||
| .object({ | |||
| fromKey: z.string(), | |||
| toKey: z.string(), | |||
| kind: z.string(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceGraphNodeRefSchema = z | |||
| .object({ | |||
| key: z.string(), | |||
| kind: z.string(), | |||
| scopeKey: nullableStr, | |||
| refCode: nullableStr, | |||
| traceLotNo: nullableStr, | |||
| traceItemCode: nullableStr, | |||
| warehouseCode: nullableStr, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceGraphSchema = z | |||
| .object({ | |||
| nodes: z.array(ItemLotTraceGraphNodeRefSchema), | |||
| edges: z.array(ItemLotTraceGraphEdgeSchema), | |||
| }) | |||
| .passthrough() | |||
| .nullable() | |||
| .optional(); | |||
| export const ItemLotTraceResponseSchema = z | |||
| .object({ | |||
| lot: z | |||
| .object({ | |||
| inventoryLotId: z.number(), | |||
| lotNo: z.string(), | |||
| itemId: z.number(), | |||
| itemCode: z.string(), | |||
| itemName: z.string(), | |||
| expiryDate: nullableStr, | |||
| productionDate: nullableStr, | |||
| stockInDate: nullableStr, | |||
| uom: z.string(), | |||
| primaryStockInLineId: nullableNum, | |||
| }) | |||
| .passthrough(), | |||
| warehouseLines: z.array(z.record(z.unknown())), | |||
| origins: z.array(ItemLotTraceOriginSchema), | |||
| purchaseEvents: z.array(z.record(z.unknown())).optional(), | |||
| qcResults: z.array(ItemLotTraceQcResultSchema), | |||
| putawayEvents: z.array(ItemLotTracePutawayEventSchema), | |||
| movements: z.array(z.record(z.unknown())), | |||
| outboundUsage: z.array(z.record(z.unknown())), | |||
| stockTakeEvents: z.array(z.record(z.unknown())), | |||
| adjustments: z.array(z.record(z.unknown())), | |||
| transfers: z.array(z.record(z.unknown())), | |||
| bomTrace: z.record(z.unknown()), | |||
| joPrelude: ItemLotTraceJoPreludeSchema.nullable(), | |||
| productionSteps: z.array(ItemLotTraceProductionStepSchema), | |||
| byproductLots: z.array(z.record(z.unknown())), | |||
| openMovements: z.array(z.record(z.unknown())), | |||
| failEvents: z.array(z.record(z.unknown())), | |||
| doDeliveries: z.array(z.record(z.unknown())), | |||
| replenishmentEvents: z.array(z.record(z.unknown())).optional(), | |||
| returnEvents: z.array(z.record(z.unknown())), | |||
| lotRelations: z.array(z.record(z.unknown())), | |||
| alternateLocations: z.array(z.record(z.unknown())), | |||
| locationBlocks: z.array(z.record(z.unknown())).optional(), | |||
| traceGraph: ItemLotTraceGraphSchema, | |||
| }) | |||
| .passthrough(); | |||
| export type ItemLotTraceResponseParsed = z.infer<typeof ItemLotTraceResponseSchema>; | |||
| export const parseItemLotTraceResponse = (data: unknown): ItemLotTraceResponseParsed => | |||
| ItemLotTraceResponseSchema.parse(data); | |||
| /** Collect dot-path keys for contract snapshot tests. */ | |||
| export const collectObjectPaths = ( | |||
| obj: unknown, | |||
| prefix = "", | |||
| depth = 0, | |||
| maxDepth = 6, | |||
| ): string[] => { | |||
| if (obj == null || depth > maxDepth) return prefix ? [prefix] : []; | |||
| if (Array.isArray(obj)) { | |||
| if (obj.length === 0) return prefix ? [prefix] : []; | |||
| return collectObjectPaths(obj[0], prefix ? `${prefix}[]` : "[]", depth + 1, maxDepth); | |||
| } | |||
| if (typeof obj !== "object") return prefix ? [prefix] : []; | |||
| const paths: string[] = prefix ? [prefix] : []; | |||
| Object.keys(obj as Record<string, unknown>).sort().forEach((key) => { | |||
| const next = prefix ? `${prefix}.${key}` : key; | |||
| paths.push(...collectObjectPaths((obj as Record<string, unknown>)[key], next, depth + 1, maxDepth)); | |||
| }); | |||
| return paths; | |||
| }; | |||
| @@ -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()); | |||
| @@ -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); | |||
| @@ -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; | |||
| }; | |||
| @@ -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", | |||
| @@ -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", | |||
| @@ -147,8 +147,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| setTab(newTab); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| params.set("tab", String(newTab)); | |||
| /* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */ | |||
| if (newTab !== 2) { | |||
| /* ticketNo / targetDate 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<Props> = ({ defaultTabIndex = 0, printerCom | |||
| </TabPanel> | |||
| <TabPanel value={tab} index={3}> | |||
| <GoodPickExecutionWorkbenchRecord | |||
| key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`} | |||
| printerCombo={printerCombo} | |||
| listScope="all" | |||
| a4Printer={a4Printer} | |||
| labelPrinter={labelPrinter} | |||
| initialTicketNo={urlTicketNo} | |||
| initialTargetDate={urlTargetDate} | |||
| /> | |||
| </TabPanel> | |||
| <TabPanel value={tab} index={4}> | |||
| @@ -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<Props> = ({ | |||
| [], | |||
| ); | |||
| 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); | |||
| @@ -0,0 +1,151 @@ | |||
| "use client"; | |||
| import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchItemLotTrace } from "@/app/api/itemTracing/actions"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError"; | |||
| import ItemTracingScanBar from "./ItemTracingScanBar"; | |||
| import ItemTracingSummary from "./ItemTracingSummary"; | |||
| import ItemTracingFlowGraph from "./ItemTracingFlowGraph"; | |||
| import ItemTracingSections from "./ItemTracingSections"; | |||
| import { | |||
| compileTraceGraph, | |||
| type CompiledTraceGraph, | |||
| } from "./compileTraceGraph"; | |||
| import { buildTraceGraphLabels } from "./traceGraphLabels"; | |||
| import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx"; | |||
| export type WarehouseFocusRequest = { | |||
| inventoryLotId: number; | |||
| warehouseCode: string; | |||
| }; | |||
| const ItemTracing: React.FC = () => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const searchParams = useSearchParams(); | |||
| const initialTraceRan = useRef(false); | |||
| const [data, setData] = useState<ItemLotTraceResponse | null>(null); | |||
| const [loading, setLoading] = useState(false); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [focusWarehouse, setFocusWarehouse] = | |||
| useState<WarehouseFocusRequest | null>(null); | |||
| const runTrace = useCallback( | |||
| async (params: { | |||
| stockInLineId?: number; | |||
| inventoryLotLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }) => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const res = await fetchItemLotTrace(params); | |||
| setData(res); | |||
| setFocusWarehouse(null); | |||
| } catch (e) { | |||
| console.error(e); | |||
| if (isNotFoundServerFetchError(e)) { | |||
| setError(t("notFound")); | |||
| } else { | |||
| setError(t("traceError")); | |||
| } | |||
| setData(null); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, | |||
| [t], | |||
| ); | |||
| useEffect(() => { | |||
| if (initialTraceRan.current) return; | |||
| const stockInLineIdRaw = searchParams.get("stockInLineId"); | |||
| const lotNo = searchParams.get("lotNo")?.trim(); | |||
| const itemCode = searchParams.get("itemCode")?.trim(); | |||
| const stockInLineId = stockInLineIdRaw | |||
| ? Number(stockInLineIdRaw) | |||
| : undefined; | |||
| const hasTraceParams = | |||
| (stockInLineId != null && !Number.isNaN(stockInLineId)) || | |||
| Boolean(lotNo) || | |||
| Boolean(itemCode); | |||
| if (!hasTraceParams) return; | |||
| initialTraceRan.current = true; | |||
| void runTrace({ | |||
| stockInLineId: | |||
| stockInLineId != null && !Number.isNaN(stockInLineId) | |||
| ? stockInLineId | |||
| : undefined, | |||
| lotNo: lotNo || undefined, | |||
| itemCode: itemCode || undefined, | |||
| }); | |||
| }, [runTrace, searchParams]); | |||
| const compiledGraph = useMemo((): CompiledTraceGraph | null => { | |||
| if (!data) return null; | |||
| const labels = buildTraceGraphLabels(t, data.lot.uom); | |||
| return compileTraceGraph(data, labels); | |||
| }, [data, t]); | |||
| const handleExportExcel = useCallback(() => { | |||
| if (!data || !compiledGraph) return; | |||
| exportItemLotTraceXlsx(data, compiledGraph, t); | |||
| }, [data, compiledGraph, t]); | |||
| return ( | |||
| <Stack spacing={3} sx={{ mt: 2 }}> | |||
| <Typography variant="body1" color="text.secondary"> | |||
| {t("subtitle")} | |||
| </Typography> | |||
| <ItemTracingScanBar | |||
| onTrace={runTrace} | |||
| loading={loading} | |||
| lastLotNo={data?.lot.lotNo} | |||
| /> | |||
| {loading && ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| )} | |||
| {error && !loading && <Alert severity="error">{error}</Alert>} | |||
| {!loading && !data && !error && ( | |||
| <Alert severity="info">{t("noResult")}</Alert> | |||
| )} | |||
| {data && !loading && ( | |||
| <> | |||
| <ItemTracingSummary | |||
| data={data} | |||
| onFocusWarehouse={(request) => setFocusWarehouse(request)} | |||
| onExportExcel={compiledGraph ? handleExportExcel : undefined} | |||
| /> | |||
| {compiledGraph && ( | |||
| <> | |||
| <ItemTracingFlowGraph | |||
| data={data} | |||
| compiledGraph={compiledGraph} | |||
| onTrace={runTrace} | |||
| focusWarehouse={focusWarehouse} | |||
| /> | |||
| <ItemTracingSections | |||
| data={data} | |||
| compiledGraph={compiledGraph} | |||
| onTrace={runTrace} | |||
| /> | |||
| </> | |||
| )} | |||
| </> | |||
| )} | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default ItemTracing; | |||
| @@ -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<DocLinkProps> = ({ | |||
| kind, | |||
| code, | |||
| id, | |||
| consoCode, | |||
| ticketNo, | |||
| targetDate, | |||
| workbenchTab = 3, | |||
| jodetailTab = 1, | |||
| openInNewTab = false, | |||
| }) => { | |||
| if (!code?.trim()) return <>—</>; | |||
| const linkTargetDate = normalizeTargetDateForLink(targetDate); | |||
| let href = "#"; | |||
| if (kind === "jo" && id) href = `/jo/edit?id=${id}`; | |||
| else if (kind === "po" && id) href = `/po/edit?id=${id}`; | |||
| else if (kind === "workbench" && ticketNo?.trim()) { | |||
| const params = new URLSearchParams(); | |||
| params.set("tab", String(workbenchTab)); | |||
| params.set("ticketNo", ticketNo.trim()); | |||
| if (linkTargetDate) params.set("targetDate", linkTargetDate); | |||
| 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 ( | |||
| <MuiLink | |||
| component={Link} | |||
| href={href} | |||
| underline="hover" | |||
| {...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})} | |||
| onClick={openInNewTab ? stopGraphEvent : undefined} | |||
| onMouseDown={openInNewTab ? stopGraphEvent : undefined} | |||
| > | |||
| {code} | |||
| </MuiLink> | |||
| ); | |||
| }; | |||
| export default ItemTracingDocLink; | |||
| @@ -0,0 +1,797 @@ | |||
| "use client"; | |||
| import "@xyflow/react/dist/style.css"; | |||
| import { | |||
| Background, | |||
| BackgroundVariant, | |||
| Controls, | |||
| MiniMap, | |||
| MarkerType, | |||
| Panel, | |||
| ReactFlow, | |||
| ReactFlowProvider, | |||
| useEdgesState, | |||
| useNodesState, | |||
| useReactFlow, | |||
| type Node, | |||
| } from "@xyflow/react"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| IconButton, | |||
| Paper, | |||
| Popover, | |||
| Stack, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; | |||
| import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; | |||
| import RestartAltIcon from "@mui/icons-material/RestartAlt"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import ItemTracingNodeDetailPanel from "./ItemTracingNodeDetailPanel"; | |||
| import ItemTracingFlowGraphSearch from "./ItemTracingFlowGraphSearch"; | |||
| import { | |||
| buildReactFlowGraph, | |||
| reactFlowGraphExtent, | |||
| type TraceFlowNodeData, | |||
| } from "./buildReactFlowGraph"; | |||
| import { traceFlowNodeTypes } from "./TraceFlowNodes"; | |||
| import { traceFlowEdgeTypes } from "./TraceFlowEdge"; | |||
| import { | |||
| VIEWPORT_HEIGHT, | |||
| FIT_VIEW_MIN_ZOOM, | |||
| FIT_VIEW_MAX_ZOOM, | |||
| DO_GROUP_HEADER, | |||
| } from "./traceFlowConstants"; | |||
| import { | |||
| minimapNodeColor, | |||
| phaseChipColor, | |||
| phaseLabelKey, | |||
| kindLabelKey, | |||
| } from "./traceFlowNodeUtils"; | |||
| import { searchTraceGraphNodes } from "./traceGraphSearch"; | |||
| import type { CompiledTraceGraph } from "./compileTraceGraph"; | |||
| import { TraceGraphNode } from "./buildTraceGraphNodes"; | |||
| import type { WarehouseFocusRequest } from "./ItemTracing"; | |||
| import type { TraceGraphPhase } from "./traceGraphLayout"; | |||
| type TraceParams = { | |||
| stockInLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }; | |||
| type Props = { | |||
| data: ItemLotTraceResponse; | |||
| compiledGraph: CompiledTraceGraph; | |||
| onTrace?: (params: TraceParams) => void; | |||
| focusWarehouse?: WarehouseFocusRequest | null; | |||
| }; | |||
| const DETAIL_PANEL_WIDTH = 280; | |||
| const LEGEND_ROWS: Array<{ key: string; tipKey: string }> = [ | |||
| { key: "flowLegendTime", tipKey: "flowLegendTimeTip" }, | |||
| { key: "flowLegendPhase", tipKey: "flowLegendPhaseTip" }, | |||
| { key: "flowLegendBranch", tipKey: "flowLegendBranchTip" }, | |||
| { key: "flowLegendArrow", tipKey: "flowLegendArrowTip" }, | |||
| { key: "flowLegendPrelude", tipKey: "flowLegendPreludeTip" }, | |||
| ]; | |||
| const ItemTracingFlowGraphInner: React.FC<Props> = ({ | |||
| data, | |||
| compiledGraph, | |||
| onTrace, | |||
| focusWarehouse, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const { fitView } = useReactFlow(); | |||
| const [selectedNode, setSelectedNode] = useState<TraceGraphNode | null>(null); | |||
| const [showMinimap, setShowMinimap] = useState(true); | |||
| const [searchQuery, setSearchQuery] = useState(""); | |||
| const [activeMatchIndex, setActiveMatchIndex] = useState(0); | |||
| const [legendAnchor, setLegendAnchor] = useState<HTMLElement | null>(null); | |||
| const [hiddenPhases, setHiddenPhases] = useState<Set<TraceGraphPhase>>( | |||
| () => new Set(), | |||
| ); | |||
| const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>( | |||
| () => new Set(), | |||
| ); | |||
| const layout = compiledGraph.layout; | |||
| const hasJoPrelude = Boolean(data.joPrelude); | |||
| const phaseLabels = useMemo( | |||
| () => | |||
| Object.fromEntries( | |||
| layout.phaseOrder.map((phase) => [phase, t(phaseLabelKey(phase))]), | |||
| ), | |||
| [layout.phaseOrder, t], | |||
| ); | |||
| const graphElements = useMemo( | |||
| () => buildReactFlowGraph(layout, phaseLabels, compiledGraph.edgePairs), | |||
| [layout, phaseLabels, compiledGraph.edgePairs], | |||
| ); | |||
| const nodesWithCallbacks = useMemo( | |||
| () => | |||
| graphElements.nodes.map((node) => | |||
| node.type === "traceEvent" | |||
| ? { | |||
| ...node, | |||
| data: { ...node.data, onTrace }, | |||
| } | |||
| : node, | |||
| ), | |||
| [graphElements.nodes, onTrace], | |||
| ); | |||
| const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks); | |||
| const [edges, setEdges, onEdgesChange] = useEdgesState(graphElements.edges); | |||
| const kindLabel = useCallback( | |||
| (kind: TraceGraphNode["kind"]) => t(kindLabelKey(kind)), | |||
| [t], | |||
| ); | |||
| const phaseVisible = useCallback( | |||
| (phase: TraceGraphPhase | undefined) => | |||
| phase == null || hiddenPhases.size === 0 || !hiddenPhases.has(phase), | |||
| [hiddenPhases], | |||
| ); | |||
| const visibleLayoutNodes = useMemo( | |||
| () => layout.nodes.filter((n) => phaseVisible(n.phase)), | |||
| [layout.nodes, phaseVisible], | |||
| ); | |||
| const matchIds = useMemo( | |||
| () => searchTraceGraphNodes(visibleLayoutNodes, searchQuery, kindLabel), | |||
| [visibleLayoutNodes, searchQuery, kindLabel], | |||
| ); | |||
| const activeNodeId = | |||
| matchIds.length > 0 ? matchIds[activeMatchIndex % matchIds.length] : null; | |||
| const focusFitNodes = useMemo(() => { | |||
| if (!activeNodeId) return []; | |||
| const ids = new Set<string>([activeNodeId]); | |||
| const activeRf = graphElements.nodes.find((n) => n.id === activeNodeId); | |||
| if (activeRf?.parentId) ids.add(activeRf.parentId); | |||
| graphElements.edges.forEach((edge) => { | |||
| if (edge.source === activeNodeId) ids.add(edge.target); | |||
| if (edge.target === activeNodeId) ids.add(edge.source); | |||
| }); | |||
| return Array.from(ids, (id) => ({ id })); | |||
| }, [activeNodeId, graphElements.edges, graphElements.nodes]); | |||
| useEffect(() => { | |||
| setSearchQuery(""); | |||
| setActiveMatchIndex(0); | |||
| setSelectedNode(null); | |||
| setHiddenPhases(new Set()); | |||
| setCollapsedGroupIds(new Set()); | |||
| }, [data]); | |||
| useEffect(() => { | |||
| setActiveMatchIndex(0); | |||
| }, [searchQuery]); | |||
| const togglePhase = useCallback( | |||
| (phase: TraceGraphPhase) => { | |||
| setHiddenPhases((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(phase)) next.delete(phase); | |||
| else next.add(phase); | |||
| // If every phase would be hidden, treat as "show all" | |||
| if (next.size >= layout.phaseOrder.length) return new Set(); | |||
| return next; | |||
| }); | |||
| }, | |||
| [layout.phaseOrder.length], | |||
| ); | |||
| const toggleGroupCollapse = useCallback((groupId: string) => { | |||
| setCollapsedGroupIds((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(groupId)) next.delete(groupId); | |||
| else next.add(groupId); | |||
| return next; | |||
| }); | |||
| }, []); | |||
| const resetPhaseFilter = useCallback(() => setHiddenPhases(new Set()), []); | |||
| const displayedNodes = useMemo(() => { | |||
| const hasSearch = searchQuery.trim().length > 0; | |||
| const matchSet = new Set(matchIds); | |||
| const groupIdsWithChildMatch = new Set<string>(); | |||
| visibleLayoutNodes.forEach((n) => { | |||
| if (n.doGroupId && matchSet.has(n.id)) | |||
| groupIdsWithChildMatch.add(n.doGroupId); | |||
| }); | |||
| const visibleIds = new Set(visibleLayoutNodes.map((n) => n.id)); | |||
| layout.nodes.forEach((n) => { | |||
| if (n.doGroupId && visibleIds.has(n.id)) visibleIds.add(n.doGroupId); | |||
| }); | |||
| return nodes | |||
| .filter((node) => { | |||
| if (node.type === "phaseLabel" || node.type === "dateHeader") | |||
| return true; | |||
| if (node.type === "doGroup" || node.type === "pickGroup") { | |||
| return layout.nodes.some( | |||
| (n) => n.doGroupId === node.id && phaseVisible(n.phase), | |||
| ); | |||
| } | |||
| if (node.type === "traceEvent") { | |||
| const layoutNode = (node.data as TraceFlowNodeData | undefined) | |||
| ?.layoutNode; | |||
| if ( | |||
| layoutNode?.doGroupId && | |||
| collapsedGroupIds.has(layoutNode.doGroupId) | |||
| ) { | |||
| return false; | |||
| } | |||
| return phaseVisible(layoutNode?.phase); | |||
| } | |||
| return true; | |||
| }) | |||
| .map((node) => { | |||
| if ( | |||
| node.type !== "traceEvent" && | |||
| node.type !== "doGroup" && | |||
| node.type !== "pickGroup" | |||
| ) { | |||
| return node; | |||
| } | |||
| const isMatch = | |||
| matchSet.has(node.id) || | |||
| ((node.type === "doGroup" || node.type === "pickGroup") && | |||
| groupIdsWithChildMatch.has(node.id)); | |||
| const isFocused = node.id === activeNodeId; | |||
| const isSelected = selectedNode?.id === node.id; | |||
| const isGroup = node.type === "doGroup" || node.type === "pickGroup"; | |||
| const groupCollapsed = isGroup && collapsedGroupIds.has(node.id); | |||
| const collapsedH = DO_GROUP_HEADER; | |||
| return { | |||
| ...node, | |||
| ...(groupCollapsed | |||
| ? { | |||
| style: { ...node.style, height: collapsedH }, | |||
| height: collapsedH, | |||
| measured: { | |||
| width: node.measured?.width ?? node.width ?? 0, | |||
| height: collapsedH, | |||
| }, | |||
| } | |||
| : {}), | |||
| data: { | |||
| ...node.data, | |||
| searchActive: hasSearch, | |||
| searchMatch: isMatch, | |||
| searchFocused: isFocused, | |||
| nodeSelected: isSelected, | |||
| ...(isGroup | |||
| ? { | |||
| groupCollapsed, | |||
| onToggleGroupCollapse: () => toggleGroupCollapse(node.id), | |||
| } | |||
| : {}), | |||
| }, | |||
| }; | |||
| }); | |||
| }, [ | |||
| nodes, | |||
| searchQuery, | |||
| matchIds, | |||
| activeNodeId, | |||
| selectedNode?.id, | |||
| layout.nodes, | |||
| visibleLayoutNodes, | |||
| phaseVisible, | |||
| collapsedGroupIds, | |||
| toggleGroupCollapse, | |||
| ]); | |||
| const displayedEdges = useMemo(() => { | |||
| const visibleEventIds = new Set( | |||
| displayedNodes | |||
| .filter( | |||
| (n) => | |||
| n.type === "traceEvent" || | |||
| n.type === "doGroup" || | |||
| n.type === "pickGroup", | |||
| ) | |||
| .map((n) => n.id), | |||
| ); | |||
| const phaseFiltered = edges.filter( | |||
| (edge) => | |||
| visibleEventIds.has(edge.source) && visibleEventIds.has(edge.target), | |||
| ); | |||
| const hasSearch = searchQuery.trim().length > 0 && matchIds.length > 0; | |||
| const selectedId = selectedNode?.id ?? null; | |||
| const selectedGroupId = selectedNode?.doGroupId?.trim() || null; | |||
| const hasSelection = Boolean(selectedId); | |||
| if (!hasSearch && !hasSelection) return phaseFiltered; | |||
| const matchSet = hasSearch ? new Set(matchIds) : null; | |||
| const selectionIds = new Set<string>(); | |||
| if (selectedId) { | |||
| selectionIds.add(selectedId); | |||
| if (selectedGroupId) selectionIds.add(selectedGroupId); | |||
| } | |||
| const focusStroke = "#1565c0"; | |||
| const matchStroke = "#ef6c00"; | |||
| const selectStroke = "#2e7d32"; | |||
| return phaseFiltered.map((edge) => { | |||
| const sourceMatch = matchSet?.has(edge.source) ?? false; | |||
| const targetMatch = matchSet?.has(edge.target) ?? false; | |||
| const connectsHighlighted = | |||
| Boolean(matchSet) && sourceMatch && targetMatch; | |||
| const touchesFocus = Boolean( | |||
| activeNodeId && | |||
| (edge.source === activeNodeId || edge.target === activeNodeId), | |||
| ); | |||
| const touchesSelection = | |||
| hasSelection && | |||
| (selectionIds.has(edge.source) || selectionIds.has(edge.target)); | |||
| const shouldHighlight = | |||
| connectsHighlighted || touchesFocus || touchesSelection; | |||
| if (!shouldHighlight) { | |||
| return { | |||
| ...edge, | |||
| zIndex: 0, | |||
| style: { | |||
| ...edge.style, | |||
| stroke: "#bdbdbd", | |||
| strokeWidth: 1, | |||
| opacity: 0.18, | |||
| }, | |||
| labelStyle: { ...edge.labelStyle, opacity: 0.2 }, | |||
| markerEnd: { | |||
| type: MarkerType.ArrowClosed, | |||
| color: "#bdbdbd", | |||
| width: 14, | |||
| height: 14, | |||
| }, | |||
| }; | |||
| } | |||
| const stroke = touchesSelection | |||
| ? selectStroke | |||
| : touchesFocus | |||
| ? focusStroke | |||
| : matchStroke; | |||
| const strokeWidth = touchesSelection || touchesFocus ? 3 : 2.5; | |||
| const zIndex = touchesSelection ? 4 : touchesFocus ? 3 : 2; | |||
| return { | |||
| ...edge, | |||
| zIndex, | |||
| style: { | |||
| ...edge.style, | |||
| stroke, | |||
| strokeWidth, | |||
| opacity: 1, | |||
| }, | |||
| labelStyle: { | |||
| ...edge.labelStyle, | |||
| fill: touchesSelection | |||
| ? selectStroke | |||
| : touchesFocus | |||
| ? focusStroke | |||
| : "#e65100", | |||
| fontWeight: 700, | |||
| opacity: 1, | |||
| }, | |||
| labelBgStyle: { | |||
| ...edge.labelBgStyle, | |||
| fill: touchesSelection | |||
| ? "#e8f5e9" | |||
| : touchesFocus | |||
| ? "#e3f2fd" | |||
| : "#fff8e1", | |||
| fillOpacity: 1, | |||
| }, | |||
| markerEnd: { | |||
| type: MarkerType.ArrowClosed, | |||
| color: stroke, | |||
| width: touchesSelection || touchesFocus ? 18 : 16, | |||
| height: touchesSelection || touchesFocus ? 18 : 16, | |||
| }, | |||
| }; | |||
| }); | |||
| }, [ | |||
| edges, | |||
| searchQuery, | |||
| matchIds, | |||
| activeNodeId, | |||
| selectedNode, | |||
| displayedNodes, | |||
| ]); | |||
| useEffect(() => { | |||
| setNodes(nodesWithCallbacks); | |||
| setEdges(graphElements.edges); | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| padding: 0.04, | |||
| minZoom: FIT_VIEW_MIN_ZOOM, | |||
| maxZoom: FIT_VIEW_MAX_ZOOM, | |||
| duration: 200, | |||
| }); | |||
| }, 50); | |||
| return () => window.clearTimeout(timer); | |||
| }, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]); | |||
| useEffect(() => { | |||
| if (!activeNodeId || !searchQuery.trim()) return; | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| nodes: focusFitNodes, | |||
| padding: 0.55, | |||
| duration: 280, | |||
| maxZoom: 1.25, | |||
| }); | |||
| }, 60); | |||
| return () => window.clearTimeout(timer); | |||
| }, [activeNodeId, searchQuery, focusFitNodes, fitView]); | |||
| useEffect(() => { | |||
| if (!focusWarehouse) return; | |||
| const { inventoryLotId, warehouseCode } = focusWarehouse; | |||
| const locPrefix = `loc-${inventoryLotId}-`; | |||
| const wh = warehouseCode.trim().toUpperCase(); | |||
| const focusIds = layout.nodes | |||
| .filter((n) => { | |||
| if (n.id.startsWith(locPrefix)) return true; | |||
| if (inventoryLotId !== data.lot.inventoryLotId) return false; | |||
| if (n.id.startsWith("loc-")) return false; | |||
| if (!wh) return true; | |||
| return (n.warehouseCode ?? "").trim().toUpperCase() === wh; | |||
| }) | |||
| .map((n) => n.id); | |||
| if (focusIds.length === 0) return; | |||
| setSearchQuery(warehouseCode.trim() || String(inventoryLotId)); | |||
| setActiveMatchIndex(0); | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| nodes: focusIds.map((id) => ({ id })), | |||
| padding: 0.2, | |||
| duration: 320, | |||
| maxZoom: 1.1, | |||
| }); | |||
| }, 80); | |||
| return () => window.clearTimeout(timer); | |||
| }, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]); | |||
| const handleSearchPrev = useCallback(() => { | |||
| if (matchIds.length === 0) return; | |||
| setActiveMatchIndex( | |||
| (prev) => (prev - 1 + matchIds.length) % matchIds.length, | |||
| ); | |||
| }, [matchIds.length]); | |||
| const handleSearchNext = useCallback(() => { | |||
| if (matchIds.length === 0) return; | |||
| setActiveMatchIndex((prev) => (prev + 1) % matchIds.length); | |||
| }, [matchIds.length]); | |||
| const handleSearchClear = useCallback(() => { | |||
| setSearchQuery(""); | |||
| setActiveMatchIndex(0); | |||
| }, []); | |||
| const onNodeClick = useCallback( | |||
| (_: React.MouseEvent, node: Node<TraceFlowNodeData>) => { | |||
| if (node.type === "traceEvent" && node.data?.layoutNode?.id) { | |||
| setSelectedNode(node.data.layoutNode); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const translateExtent = useMemo( | |||
| () => reactFlowGraphExtent(layout, graphElements.graphHeight), | |||
| [layout, graphElements.graphHeight], | |||
| ); | |||
| if (!layout.nodes.length) { | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Typography color="text.secondary">{t("noRecords")}</Typography> | |||
| </Paper> | |||
| ); | |||
| } | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| alignItems={{ sm: "center" }} | |||
| spacing={1} | |||
| sx={{ mb: 1 }} | |||
| > | |||
| <Typography variant="subtitle1" fontWeight={600} sx={{ flexGrow: 1 }}> | |||
| {t("flowGraph")} | |||
| </Typography> | |||
| <Tooltip title={t("flowLegendOpen")} arrow> | |||
| <IconButton | |||
| size="small" | |||
| aria-label={t("flowLegendOpen")} | |||
| onClick={(e) => setLegendAnchor(e.currentTarget)} | |||
| > | |||
| <InfoOutlinedIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </Stack> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| sx={{ mb: 1 }} | |||
| > | |||
| {hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")} | |||
| {" · "} | |||
| {t("flowZoomPanHint")} | |||
| </Typography> | |||
| <Stack spacing={0.75} alignItems="center" sx={{ mb: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowPhaseFilter")} | |||
| </Typography> | |||
| <Stack | |||
| direction="row" | |||
| flexWrap="wrap" | |||
| gap={0.75} | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| > | |||
| {layout.phaseOrder.map((phase) => { | |||
| const active = phaseVisible(phase); | |||
| const chipColor = phaseChipColor(phase); | |||
| return ( | |||
| <Chip | |||
| key={phase} | |||
| size="small" | |||
| label={t(phaseLabelKey(phase))} | |||
| color={chipColor} | |||
| variant={active ? "filled" : "outlined"} | |||
| onClick={() => togglePhase(phase)} | |||
| sx={{ | |||
| borderRadius: 1.5, | |||
| height: 28, | |||
| fontWeight: 600, | |||
| cursor: "pointer", | |||
| letterSpacing: 0.1, | |||
| opacity: active ? 1 : 0.8, | |||
| transition: "box-shadow 0.15s ease, opacity 0.15s ease, filter 0.15s ease", | |||
| "& .MuiChip-label": { px: 1.25 }, | |||
| "&:hover": { | |||
| opacity: 1, | |||
| boxShadow: 1, | |||
| filter: active ? "brightness(1.06)" : undefined, | |||
| }, | |||
| }} | |||
| /> | |||
| ); | |||
| })} | |||
| <Chip | |||
| size="small" | |||
| icon={<RestartAltIcon />} | |||
| label={t("flowPhaseFilterReset")} | |||
| color="error" | |||
| variant="filled" | |||
| disabled={hiddenPhases.size === 0} | |||
| onClick={hiddenPhases.size > 0 ? resetPhaseFilter : undefined} | |||
| sx={{ | |||
| borderRadius: 1.5, | |||
| height: 28, | |||
| fontWeight: 700, | |||
| cursor: hiddenPhases.size === 0 ? "default" : "pointer", | |||
| letterSpacing: 0.2, | |||
| "& .MuiChip-icon": { ml: 0.5, fontSize: 16 }, | |||
| "& .MuiChip-label": { px: 1.25 }, | |||
| "&:hover": | |||
| hiddenPhases.size === 0 | |||
| ? undefined | |||
| : { | |||
| boxShadow: 1, | |||
| filter: "brightness(1.08)", | |||
| }, | |||
| }} | |||
| /> | |||
| </Stack> | |||
| </Stack> | |||
| <Popover | |||
| open={Boolean(legendAnchor)} | |||
| anchorEl={legendAnchor} | |||
| onClose={() => setLegendAnchor(null)} | |||
| anchorOrigin={{ vertical: "bottom", horizontal: "right" }} | |||
| transformOrigin={{ vertical: "top", horizontal: "right" }} | |||
| > | |||
| <Box sx={{ p: 2, maxWidth: 360 }}> | |||
| <Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}> | |||
| {t("flowLegendTitle")} | |||
| </Typography> | |||
| <Stack spacing={1}> | |||
| {LEGEND_ROWS.map(({ key, tipKey }) => ( | |||
| <Box key={key}> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {t(key)} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t(tipKey)} | |||
| </Typography> | |||
| </Box> | |||
| ))} | |||
| {hasJoPrelude && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowGraphPathJo")} | |||
| </Typography> | |||
| )} | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowNodeDetailHint")} | |||
| </Typography> | |||
| </Stack> | |||
| </Box> | |||
| </Popover> | |||
| <Box | |||
| sx={{ | |||
| height: VIEWPORT_HEIGHT, | |||
| borderRadius: 1, | |||
| border: 1, | |||
| borderColor: "divider", | |||
| overflow: "hidden", | |||
| bgcolor: "action.hover", | |||
| display: "flex", | |||
| "& .react-flow__attribution": { display: "none" }, | |||
| }} | |||
| > | |||
| {selectedNode && ( | |||
| <Box | |||
| sx={{ width: DETAIL_PANEL_WIDTH, flexShrink: 0, height: "100%" }} | |||
| > | |||
| <ItemTracingNodeDetailPanel | |||
| node={selectedNode} | |||
| onClose={() => setSelectedNode(null)} | |||
| /> | |||
| </Box> | |||
| )} | |||
| <Box | |||
| sx={{ flex: 1, minWidth: 0, height: "100%", position: "relative" }} | |||
| > | |||
| <ReactFlow | |||
| nodes={displayedNodes} | |||
| edges={displayedEdges} | |||
| onNodesChange={onNodesChange} | |||
| onEdgesChange={onEdgesChange} | |||
| elevateEdgesOnSelect={false} | |||
| defaultEdgeOptions={{ zIndex: 0 }} | |||
| nodeTypes={traceFlowNodeTypes} | |||
| edgeTypes={traceFlowEdgeTypes} | |||
| onNodeClick={onNodeClick} | |||
| onPaneClick={() => setSelectedNode(null)} | |||
| nodesDraggable={false} | |||
| nodesConnectable={false} | |||
| elementsSelectable | |||
| panOnScroll | |||
| zoomOnScroll | |||
| minZoom={0.15} | |||
| maxZoom={2.5} | |||
| translateExtent={translateExtent} | |||
| proOptions={{ hideAttribution: true }} | |||
| style={{ width: "100%", height: "100%" }} | |||
| > | |||
| <Panel position="top-right"> | |||
| <ItemTracingFlowGraphSearch | |||
| query={searchQuery} | |||
| matchCount={matchIds.length} | |||
| activeIndex={activeMatchIndex} | |||
| onQueryChange={setSearchQuery} | |||
| onPrev={handleSearchPrev} | |||
| onNext={handleSearchNext} | |||
| onClear={handleSearchClear} | |||
| /> | |||
| </Panel> | |||
| <Background | |||
| variant={BackgroundVariant.Dots} | |||
| gap={16} | |||
| size={1} | |||
| color="#bdbdbd" | |||
| /> | |||
| <Panel position="bottom-left" style={{ left: 12, bottom: 12 }}> | |||
| <Stack spacing={0.5} alignItems="center"> | |||
| <Tooltip | |||
| title={ | |||
| showMinimap ? t("flowMinimapHide") : t("flowMinimapShow") | |||
| } | |||
| arrow | |||
| placement="right" | |||
| > | |||
| <IconButton | |||
| size="small" | |||
| onClick={() => setShowMinimap((v) => !v)} | |||
| aria-label={ | |||
| showMinimap ? t("flowMinimapHide") : t("flowMinimapShow") | |||
| } | |||
| sx={{ | |||
| bgcolor: "background.paper", | |||
| border: 1, | |||
| borderColor: "divider", | |||
| boxShadow: 1, | |||
| borderRadius: 1, | |||
| width: 28, | |||
| height: 28, | |||
| "&:hover": { bgcolor: "background.paper" }, | |||
| }} | |||
| > | |||
| <MapOutlinedIcon | |||
| fontSize="small" | |||
| color={showMinimap ? "primary" : "action"} | |||
| /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| <Box | |||
| sx={{ | |||
| "& .react-flow__controls": { | |||
| position: "static", | |||
| margin: 0, | |||
| boxShadow: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <Controls showInteractive={false} /> | |||
| </Box> | |||
| </Stack> | |||
| </Panel> | |||
| {showMinimap && ( | |||
| <MiniMap | |||
| nodeColor={(node) => { | |||
| if ( | |||
| node.type !== "traceEvent" && | |||
| node.type !== "doGroup" && | |||
| node.type !== "pickGroup" | |||
| ) { | |||
| return "#e0e0e0"; | |||
| } | |||
| const layoutNode = ( | |||
| node.data as TraceFlowNodeData | undefined | |||
| )?.layoutNode; | |||
| return layoutNode?.kind | |||
| ? minimapNodeColor(layoutNode.kind) | |||
| : "#e0e0e0"; | |||
| }} | |||
| pannable | |||
| zoomable | |||
| style={{ border: "1px solid #e0e0e0", borderRadius: 4 }} | |||
| /> | |||
| )} | |||
| </ReactFlow> | |||
| </Box> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.0 | 2026-07-14 */ | |||
| const ItemTracingFlowGraph: React.FC<Props> = (props) => ( | |||
| <ReactFlowProvider> | |||
| <ItemTracingFlowGraphInner {...props} /> | |||
| </ReactFlowProvider> | |||
| ); | |||
| export default ItemTracingFlowGraph; | |||
| @@ -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<Props> = ({ | |||
| query, | |||
| matchCount, | |||
| activeIndex, | |||
| onQueryChange, | |||
| onPrev, | |||
| onNext, | |||
| onClear, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const trimmed = query.trim(); | |||
| const hasQuery = trimmed.length > 0; | |||
| const matchLabel = | |||
| matchCount > 0 | |||
| ? t("flowGraphSearchMatch", { current: activeIndex + 1, total: matchCount }) | |||
| : hasQuery | |||
| ? t("flowGraphSearchNoMatch") | |||
| : ""; | |||
| const handleKeyDown = (event: React.KeyboardEvent) => { | |||
| if (event.key === "Enter") { | |||
| event.preventDefault(); | |||
| if (event.shiftKey) onPrev(); | |||
| else onNext(); | |||
| } else if (event.key === "Escape") { | |||
| event.preventDefault(); | |||
| onClear(); | |||
| } | |||
| }; | |||
| return ( | |||
| <Paper | |||
| elevation={2} | |||
| sx={{ | |||
| p: 1, | |||
| minWidth: 280, | |||
| maxWidth: 360, | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| value={query} | |||
| placeholder={t("flowGraphSearchPlaceholder")} | |||
| onChange={(e) => onQueryChange(e.target.value)} | |||
| onKeyDown={handleKeyDown} | |||
| InputProps={{ | |||
| startAdornment: ( | |||
| <InputAdornment position="start" sx={{ mr: 0.5 }}> | |||
| <SearchIcon fontSize="small" sx={{ color: "text.secondary" }} /> | |||
| </InputAdornment> | |||
| ), | |||
| endAdornment: hasQuery ? ( | |||
| <InputAdornment position="end"> | |||
| <Tooltip title={t("flowGraphSearchClear")}> | |||
| <IconButton size="small" edge="end" onClick={onClear} aria-label={t("flowGraphSearchClear")}> | |||
| <ClearIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </InputAdornment> | |||
| ) : undefined, | |||
| }} | |||
| sx={{ | |||
| "& .MuiOutlinedInput-root": { | |||
| 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 && ( | |||
| <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mt: 0.75, gap: 1 }}> | |||
| <Typography variant="caption" color={matchCount > 0 ? "text.secondary" : "error"} noWrap> | |||
| {matchLabel} | |||
| </Typography> | |||
| <Box sx={{ display: "flex", flexShrink: 0 }}> | |||
| <Tooltip title={t("flowGraphSearchPrev")}> | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={onPrev} | |||
| disabled={matchCount === 0} | |||
| aria-label={t("flowGraphSearchPrev")} | |||
| > | |||
| <KeyboardArrowUpIcon fontSize="small" /> | |||
| </IconButton> | |||
| </span> | |||
| </Tooltip> | |||
| <Tooltip title={t("flowGraphSearchNext")}> | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={onNext} | |||
| disabled={matchCount === 0} | |||
| aria-label={t("flowGraphSearchNext")} | |||
| > | |||
| <KeyboardArrowDownIcon fontSize="small" /> | |||
| </IconButton> | |||
| </span> | |||
| </Tooltip> | |||
| </Box> | |||
| </Box> | |||
| )} | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingFlowGraphSearch; | |||
| @@ -0,0 +1,11 @@ | |||
| import { Skeleton, Stack } from "@mui/material"; | |||
| const ItemTracingLoading: React.FC = () => ( | |||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||
| <Skeleton variant="rounded" height={120} /> | |||
| <Skeleton variant="rounded" height={200} /> | |||
| <Skeleton variant="rounded" height={320} /> | |||
| </Stack> | |||
| ); | |||
| export default ItemTracingLoading; | |||
| @@ -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<T>({ | |||
| title, | |||
| rows, | |||
| columns, | |||
| getRowKey, | |||
| }: { | |||
| title: string; | |||
| rows: T[]; | |||
| columns: FilterableColumnDef<T>[]; | |||
| getRowKey: (row: T, index: number) => string; | |||
| }) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const [showAll, setShowAll] = useState(false); | |||
| return ( | |||
| <Box> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| {title} ({rows.length}) | |||
| </Typography> | |||
| <TableContainer> | |||
| <FilterableDataTable | |||
| rows={rows} | |||
| columns={columns} | |||
| getRowKey={getRowKey} | |||
| emptyLabel={t("noRecords")} | |||
| collapseAfter={10} | |||
| showAll={showAll} | |||
| onToggleShowAll={() => setShowAll((v) => !v)} | |||
| showAllLabel={(count) => t("locationsShowAll", { count })} | |||
| collapseLabel={(count) => t("locationsCollapse", { count })} | |||
| /> | |||
| </TableContainer> | |||
| </Box> | |||
| ); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */ | |||
| const ItemTracingLocations: React.FC<Props> = ({ | |||
| locationBlocks, | |||
| onFocusWarehouse, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const tr = createTraceLabelTranslator(t); | |||
| if (!locationBlocks || locationBlocks.length === 0) return null; | |||
| return ( | |||
| <Stack spacing={1.5}> | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <Inventory2Icon fontSize="small" color="primary" /> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("locationHeader", { count: locationBlocks.length })} | |||
| </Typography> | |||
| <Chip | |||
| label={`${locationBlocks.length}`} | |||
| size="small" | |||
| color="primary" | |||
| variant="outlined" | |||
| /> | |||
| </Stack> | |||
| {locationBlocks.map((block) => { | |||
| const totalIn = block.warehouseLines.reduce( | |||
| (s, l) => s + (l.inQty || 0), | |||
| 0, | |||
| ); | |||
| const totalOut = block.warehouseLines.reduce( | |||
| (s, l) => s + (l.outQty || 0), | |||
| 0, | |||
| ); | |||
| const available = totalIn - totalOut; | |||
| const lastMovement = block.movements[0]; | |||
| const stockTakeVariance = block.stockTakeEvents.reduce( | |||
| (s, st) => s + (st.varianceQty || 0), | |||
| 0, | |||
| ); | |||
| return ( | |||
| <Accordion | |||
| key={block.inventoryLotId} | |||
| slotProps={{ transition: { unmountOnExit: true } }} | |||
| > | |||
| <AccordionSummary expandIcon={<ExpandMoreIcon />}> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={1.5} | |||
| flexWrap="wrap" | |||
| sx={{ width: "100%", gap: 0.5 }} | |||
| > | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {block.warehouseLines | |||
| .map((l) => l.warehouseCode) | |||
| .filter(Boolean) | |||
| .join(" / ") || `Lot #${block.inventoryLotId}`} | |||
| </Typography> | |||
| {block.itemName && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {block.itemCode} ??{block.itemName} | |||
| </Typography> | |||
| )} | |||
| <Chip | |||
| label={`${t("available")}: ${available}`} | |||
| size="small" | |||
| color={available > 0 ? "success" : "default"} | |||
| variant="outlined" | |||
| /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("inQty")}: {totalIn} / {t("outQty")}: {totalOut} | |||
| </Typography> | |||
| {block.movements.length > 0 && ( | |||
| <Chip | |||
| label={`${block.movements.length} moves`} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| )} | |||
| {lastMovement?.timestamp && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("lastMove")}: {lastMovement.timestamp} | |||
| </Typography> | |||
| )} | |||
| {stockTakeVariance !== 0 && ( | |||
| <Chip | |||
| label={`? ${stockTakeVariance}`} | |||
| size="small" | |||
| color={stockTakeVariance !== 0 ? "warning" : "default"} | |||
| variant="outlined" | |||
| /> | |||
| )} | |||
| {onFocusWarehouse ? ( | |||
| <Chip | |||
| label={t("focusWarehouseInGraph")} | |||
| size="small" | |||
| color="primary" | |||
| variant="filled" | |||
| clickable | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| onFocusWarehouse({ | |||
| inventoryLotId: block.inventoryLotId, | |||
| warehouseCode: blockWarehouseCode(block), | |||
| }); | |||
| }} | |||
| sx={{ ml: "auto" }} | |||
| /> | |||
| ) : null} | |||
| </Stack> | |||
| </AccordionSummary> | |||
| <AccordionDetails> | |||
| <Stack spacing={2}> | |||
| {block.warehouseLines.length > 0 && ( | |||
| <SectionTable | |||
| title={t("warehouseLines")} | |||
| rows={block.warehouseLines} | |||
| getRowKey={(wl) => String(wl.inventoryLotLineId)} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (wl) => wl.warehouseCode, | |||
| }, | |||
| { | |||
| key: "inQty", | |||
| label: t("inQty"), | |||
| align: "right", | |||
| value: (wl) => wl.inQty, | |||
| }, | |||
| { | |||
| key: "outQty", | |||
| label: t("outQty"), | |||
| align: "right", | |||
| value: (wl) => wl.outQty, | |||
| }, | |||
| { | |||
| key: "available", | |||
| label: t("available"), | |||
| align: "right", | |||
| value: (wl) => wl.availableQty, | |||
| }, | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (wl) => tr.lotLineStatus(wl.status), | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.origins.length > 0 && ( | |||
| <SectionTable | |||
| title={t("origins")} | |||
| rows={block.origins} | |||
| getRowKey={(o) => String(o.stockInLineId)} | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => o.type, | |||
| cell: (o) => ( | |||
| <Chip | |||
| label={o.type} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (o) => o.refCode, | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (o) => o.supplierName || o.supplierCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => o.acceptedQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (o) => o.receiptDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.qcResults.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabQc")} | |||
| rows={block.qcResults} | |||
| getRowKey={(qc) => `${qc.stockInLineId}-${qc.created}`} | |||
| columns={[ | |||
| { | |||
| key: "qcPassed", | |||
| label: t("qcPassed"), | |||
| value: (qc) => | |||
| qc.qcPassed ? t("qcPassed") : t("qcFailed"), | |||
| cell: (qc) => ( | |||
| <Chip | |||
| label={qc.qcPassed ? t("qcPassed") : t("qcFailed")} | |||
| size="small" | |||
| color={qc.qcPassed ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "acceptedQty", | |||
| label: t("acceptedQty"), | |||
| align: "right", | |||
| value: (qc) => qc.acceptedQty, | |||
| }, | |||
| { | |||
| key: "failQty", | |||
| label: t("failQty"), | |||
| align: "right", | |||
| value: (qc) => qc.failQty, | |||
| }, | |||
| { | |||
| key: "qcType", | |||
| label: t("detailQcType"), | |||
| value: (qc) => qc.qcType, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (qc) => qc.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (qc) => qc.created, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.purchaseEvents && block.purchaseEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("categoryPurchase")} | |||
| rows={block.purchaseEvents} | |||
| getRowKey={(pe) => String(pe.purchaseOrderLineId)} | |||
| columns={[ | |||
| { | |||
| key: "po", | |||
| label: t("detailPurchaseOrderNo"), | |||
| value: (pe) => pe.purchaseOrderCode, | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (pe) => pe.supplierName || pe.supplierCode, | |||
| }, | |||
| { | |||
| key: "orderQty", | |||
| label: t("detailOrderQty"), | |||
| align: "right", | |||
| value: (pe) => pe.orderQty, | |||
| }, | |||
| { | |||
| key: "putAwayQty", | |||
| label: t("detailPutAwayQty"), | |||
| align: "right", | |||
| value: (pe) => pe.putAwayQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (pe) => pe.orderDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.putawayEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodePutaway")} | |||
| rows={block.putawayEvents} | |||
| getRowKey={(pa, idx) => `${pa.inventoryLotLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (pa) => pa.warehouseCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (pa) => pa.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (pa) => pa.handledBy, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (pa) => pa.refCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (pa) => pa.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.movements.length > 0 && ( | |||
| <SectionTable | |||
| title={t("movements")} | |||
| rows={block.movements} | |||
| getRowKey={(m, idx) => `${m.refCode}-${m.timestamp}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "direction", | |||
| label: t("direction"), | |||
| value: (m) => m.direction, | |||
| cell: (m) => ( | |||
| <Chip | |||
| label={m.direction} | |||
| size="small" | |||
| color={m.direction === "IN" ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (m) => m.refType, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (m) => m.refCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (m) => m.qty, | |||
| }, | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (m) => m.warehouseCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (m) => m.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.outboundUsage.length > 0 && ( | |||
| <SectionTable | |||
| title={t("outbound")} | |||
| rows={block.outboundUsage} | |||
| getRowKey={(o, idx) => `${o.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (o) => o.pickOrderCode || o.deliveryOrderCode, | |||
| }, | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => tr.usageType(o.usageType), | |||
| cell: (o) => ( | |||
| <Chip | |||
| label={tr.usageType(o.usageType)} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => o.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (o) => o.handler, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (o) => o.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.stockTakeEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("stockTake")} | |||
| rows={block.stockTakeEvents} | |||
| getRowKey={(st, idx) => `${st.stockTakeCode}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "code", | |||
| label: t("stockTakeCode"), | |||
| value: (st) => st.stockTakeCode, | |||
| }, | |||
| { | |||
| key: "section", | |||
| label: t("section"), | |||
| value: (st) => st.stockTakeSection, | |||
| }, | |||
| { | |||
| key: "round", | |||
| label: t("round"), | |||
| value: (st) => st.stockTakeRoundName, | |||
| }, | |||
| { | |||
| key: "before", | |||
| label: t("before"), | |||
| align: "right", | |||
| value: (st) => | |||
| resolveStockTakeBookQty(st.recordDetail, st.beforeQty), | |||
| }, | |||
| { | |||
| key: "after", | |||
| label: t("after"), | |||
| align: "right", | |||
| value: (st) => | |||
| resolveStockTakeAcceptedQty( | |||
| st.recordDetail, | |||
| st.afterQty, | |||
| ), | |||
| }, | |||
| { | |||
| key: "variance", | |||
| label: t("variance"), | |||
| align: "right", | |||
| value: (st) => | |||
| st.recordDetail?.varianceQty != null | |||
| ? st.recordDetail.varianceQty | |||
| : st.varianceQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (st) => st.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.adjustments.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabAdjustments")} | |||
| rows={block.adjustments} | |||
| getRowKey={(adj, idx) => | |||
| `${adj.refCode}-${adj.timestamp}-${idx}` | |||
| } | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (adj) => adj.adjustmentType, | |||
| }, | |||
| { | |||
| key: "direction", | |||
| label: t("direction"), | |||
| value: (adj) => adj.direction, | |||
| cell: (adj) => ( | |||
| <Chip | |||
| label={adj.direction} | |||
| size="small" | |||
| color={adj.direction === "IN" ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (adj) => adj.qty, | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (adj) => adj.reason, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (adj) => adj.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (adj) => adj.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.transfers.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabTransfers")} | |||
| rows={block.transfers} | |||
| getRowKey={(xfer, idx) => `${xfer.transferCode}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "from", | |||
| label: t("from"), | |||
| value: (xfer) => xfer.fromWarehouse, | |||
| }, | |||
| { | |||
| key: "to", | |||
| label: t("to"), | |||
| value: (xfer) => xfer.toWarehouse, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (xfer) => xfer.qty, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (xfer) => xfer.transferCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (xfer) => xfer.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.doDeliveries.length > 0 && ( | |||
| <SectionTable | |||
| title={t("deliveryOrder")} | |||
| rows={block.doDeliveries} | |||
| getRowKey={(dd, idx) => `${dd.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (dd) => dd.pickOrderCode, | |||
| }, | |||
| { | |||
| key: "deliveryOrder", | |||
| label: t("deliveryOrder"), | |||
| value: (dd) => dd.deliveryOrderCode, | |||
| }, | |||
| { | |||
| key: "deliveryNoteCode", | |||
| label: t("deliveryNoteCode"), | |||
| value: (dd) => dd.deliveryNoteCode || "—", | |||
| }, | |||
| { | |||
| key: "ticketNo", | |||
| label: t("ticketNo"), | |||
| value: (dd) => dd.ticketNo || "—", | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (dd) => dd.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (dd) => dd.handler, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (dd) => dd.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.failEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeFail")} | |||
| rows={block.failEvents} | |||
| getRowKey={(fe) => String(fe.failId)} | |||
| columns={[ | |||
| { | |||
| key: "category", | |||
| label: t("detailFailCategory"), | |||
| value: (fe) => `${fe.failType} / ${fe.category}`, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (fe) => fe.qty, | |||
| }, | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (fe) => fe.pickOrderCode, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (fe) => fe.handlerName, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (fe) => fe.recordDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.returnEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeReturn")} | |||
| rows={block.returnEvents} | |||
| getRowKey={(re, idx) => `${re.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (re) => re.movementType, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (re) => re.qty, | |||
| }, | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (re) => re.warehouseCode, | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (re) => re.remarks, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (re) => re.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.openMovements.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeOpen")} | |||
| rows={block.openMovements} | |||
| getRowKey={(om) => String(om.stockInLineId)} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (om) => om.warehouseCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (om) => om.qty, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (om) => om.refCode, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (om) => om.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (om) => om.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| </Stack> | |||
| </AccordionDetails> | |||
| </Accordion> | |||
| ); | |||
| })} | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default ItemTracingLocations; | |||
| @@ -0,0 +1,50 @@ | |||
| "use client"; | |||
| import Link from "next/link"; | |||
| import MuiLink, { type LinkProps as MuiLinkProps } from "@mui/material/Link"; | |||
| import { buildItemTracingHref } from "./traceNavigationUtils"; | |||
| type Props = { | |||
| label: string; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| stockInLineId?: number; | |||
| /** Prevent React Flow node selection when the link sits on a graph card. */ | |||
| stopPropagation?: boolean; | |||
| variant?: MuiLinkProps["variant"]; | |||
| sx?: MuiLinkProps["sx"]; | |||
| }; | |||
| const stopGraphEvent = (e: React.MouseEvent) => { | |||
| e.stopPropagation(); | |||
| }; | |||
| const ItemTracingLotTraceLink: React.FC<Props> = ({ | |||
| label, | |||
| lotNo, | |||
| itemCode, | |||
| stockInLineId, | |||
| stopPropagation = false, | |||
| variant = "caption", | |||
| sx, | |||
| }) => { | |||
| if (!lotNo?.trim() && stockInLineId == null) return null; | |||
| return ( | |||
| <MuiLink | |||
| component={Link} | |||
| href={buildItemTracingHref({ lotNo, itemCode, stockInLineId })} | |||
| underline="hover" | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| variant={variant} | |||
| sx={sx} | |||
| onClick={stopPropagation ? stopGraphEvent : undefined} | |||
| onMouseDown={stopPropagation ? stopGraphEvent : undefined} | |||
| > | |||
| {label} | |||
| </MuiLink> | |||
| ); | |||
| }; | |||
| export default ItemTracingLotTraceLink; | |||
| @@ -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<Props> = ({ node, onClose }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const hasDocLink = node.docLinkKind && node.refCode; | |||
| const lifecycleStages = | |||
| node.kind === "STOCK_TAKE" && node.stockTakeRecordDetail | |||
| ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) | |||
| : []; | |||
| const activeStageCount = lifecycleStages.filter((s) => s.isActive).length; | |||
| return ( | |||
| <Paper | |||
| elevation={0} | |||
| square | |||
| sx={{ | |||
| width: "100%", | |||
| height: "100%", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| bgcolor: "background.paper", | |||
| borderRight: 1, | |||
| borderColor: "divider", | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| px: 1.5, | |||
| py: 1.25, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <Stack direction="row" spacing={0.5} alignItems="flex-start" justifyContent="space-between"> | |||
| <Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}> | |||
| <Typography variant="subtitle2" fontWeight={700}> | |||
| {t("nodeDetailTitle")} | |||
| </Typography> | |||
| {node.categoryLabel && ( | |||
| <Chip size="small" label={node.categoryLabel} color="primary" variant="outlined" /> | |||
| )} | |||
| </Stack> | |||
| {onClose && ( | |||
| <IconButton size="small" onClick={onClose} aria-label={t("nodeDetailClose")} sx={{ mt: -0.5 }}> | |||
| <CloseIcon fontSize="small" /> | |||
| </IconButton> | |||
| )} | |||
| </Stack> | |||
| </Box> | |||
| <Box sx={{ flex: 1, overflow: "auto", px: 1.5, py: 1.25 }}> | |||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | |||
| {hasDocLink ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind!} | |||
| code={node.refCode!} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.title | |||
| )} | |||
| </Typography> | |||
| {node.subtitle && ( | |||
| <Typography variant="caption" color="text.secondary" display="block" gutterBottom> | |||
| {node.subtitle} | |||
| </Typography> | |||
| )} | |||
| <Table size="small" sx={{ mt: 0.5 }}> | |||
| <TableBody> | |||
| {node.details.map((row, i) => ( | |||
| <TableRow key={`${row.label}-${i}`}> | |||
| <TableCell | |||
| component="th" | |||
| scope="row" | |||
| sx={{ | |||
| width: "42%", | |||
| fontWeight: 600, | |||
| color: "text.secondary", | |||
| border: 0, | |||
| py: 0.5, | |||
| pl: 0, | |||
| fontSize: "0.75rem", | |||
| verticalAlign: "top", | |||
| }} | |||
| > | |||
| {row.label} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| border: 0, | |||
| py: 0.5, | |||
| pr: 0, | |||
| fontSize: "0.8125rem", | |||
| whiteSpace: "pre-line", | |||
| verticalAlign: "top", | |||
| }} | |||
| > | |||
| {row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? ( | |||
| <List dense disablePadding sx={{ width: "100%" }}> | |||
| {row.qcCriteriaItems.map((item, idx) => ( | |||
| <ListItem | |||
| key={`${item.name}-${idx}`} | |||
| disableGutters | |||
| alignItems="flex-start" | |||
| sx={{ | |||
| py: 0.5, | |||
| borderBottom: | |||
| idx < row.qcCriteriaItems!.length - 1 ? "1px solid" : "none", | |||
| borderColor: "divider", | |||
| }} | |||
| > | |||
| <ListItemText | |||
| primary={ | |||
| <Stack | |||
| direction="row" | |||
| spacing={0.5} | |||
| alignItems="center" | |||
| flexWrap="wrap" | |||
| useFlexGap | |||
| > | |||
| <Typography variant="caption" fontWeight={600} component="span"> | |||
| {item.name} | |||
| </Typography> | |||
| <Chip | |||
| size="small" | |||
| label={item.passed ? t("qcPassed") : t("qcFailed")} | |||
| color={item.passed ? "success" : "error"} | |||
| variant="outlined" | |||
| sx={{ height: 20, "& .MuiChip-label": { px: 0.75, fontSize: "0.65rem" } }} | |||
| /> | |||
| {!item.passed && item.failQty != null && item.failQty > 0 && ( | |||
| <Typography variant="caption" color="error.main" component="span"> | |||
| {t("failQty")}: {formatQty(item.failQty, node.uom)} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| } | |||
| secondary={ | |||
| item.description ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ display: "block", mt: 0.25, lineHeight: 1.4 }} | |||
| > | |||
| {item.description} | |||
| </Typography> | |||
| ) : null | |||
| } | |||
| /> | |||
| </ListItem> | |||
| ))} | |||
| </List> | |||
| ) : row.linkKind && row.linkCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={row.linkKind} | |||
| code={row.linkCode} | |||
| id={row.linkId} | |||
| consoCode={row.consoCode ?? row.linkCode} | |||
| ticketNo={row.linkTicketNo} | |||
| targetDate={row.linkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| <Typography | |||
| component="span" | |||
| sx={{ | |||
| fontSize: "inherit", | |||
| fontWeight: row.valueColor && row.valueColor !== "default" ? 700 : undefined, | |||
| color: | |||
| row.valueColor && row.valueColor !== "default" | |||
| ? `${row.valueColor}.main` | |||
| : undefined, | |||
| }} | |||
| > | |||
| {row.value || "—"} | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| {node.traceLotNo ? ( | |||
| <Box sx={{ mt: 1.5 }}> | |||
| <ItemTracingLotTraceLink | |||
| label={ | |||
| node.kind === "BYPRODUCT" | |||
| ? t("traceByproductLot") | |||
| : node.kind === "REPACK" | |||
| ? t("traceRepackLot") | |||
| : t("traceMaterialLot") | |||
| } | |||
| lotNo={node.traceLotNo} | |||
| itemCode={node.traceItemCode} | |||
| variant="body2" | |||
| sx={{ display: "inline-block" }} | |||
| /> | |||
| </Box> | |||
| ) : null} | |||
| {lifecycleStages.length > 0 && ( | |||
| <> | |||
| <Divider sx={{ my: 1.5 }} /> | |||
| <Typography variant="subtitle2" fontWeight={700} gutterBottom> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })} | |||
| </Typography> | |||
| <ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} /> | |||
| </> | |||
| )} | |||
| {node.meta && ( | |||
| <> | |||
| <Divider sx={{ my: 1.5 }} /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {node.meta} | |||
| </Typography> | |||
| </> | |||
| )} | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingNodeDetailPanel; | |||
| @@ -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<ScanBarProps> = ({ | |||
| onTrace, | |||
| loading, | |||
| lastLotNo, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const scanner = useQrCodeScannerContext(); | |||
| const [scanMode, setScanMode] = useState<"idle" | "wedge">("idle"); | |||
| const [itemCode, setItemCode] = useState(""); | |||
| const [lotNo, setLotNo] = useState(""); | |||
| const [scanError, setScanError] = useState<string | null>(null); | |||
| const startWedgeScan = useCallback(() => { | |||
| setScanError(null); | |||
| setScanMode("wedge"); | |||
| scanner.resetScan(); | |||
| scanner.startScan(); | |||
| }, [scanner]); | |||
| const stopWedgeScan = useCallback(() => { | |||
| scanner.stopScan(); | |||
| scanner.resetScan(); | |||
| setScanMode("idle"); | |||
| }, [scanner]); | |||
| useEffect(() => { | |||
| if (scanMode !== "wedge") return; | |||
| const itemId = scanner.result?.itemId; | |||
| const stockInLineId = scanner.result?.stockInLineId; | |||
| if (!itemId || !stockInLineId) return; | |||
| setScanError(null); | |||
| stopWedgeScan(); | |||
| onTrace({ stockInLineId: Number(stockInLineId) }); | |||
| }, [scanMode, scanner.result, onTrace, stopWedgeScan]); | |||
| const handleManualSearch = () => { | |||
| const code = itemCode.trim(); | |||
| const lot = lotNo.trim(); | |||
| if (!code || !lot) return; | |||
| onTrace({ itemCode: code, lotNo: lot }); | |||
| }; | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Stack spacing={2}> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| spacing={1} | |||
| alignItems={{ sm: "center" }} | |||
| > | |||
| <Button | |||
| variant={scanMode === "wedge" ? "contained" : "outlined"} | |||
| startIcon={<QrCodeScannerIcon />} | |||
| onClick={scanMode === "wedge" ? stopWedgeScan : startWedgeScan} | |||
| disabled={loading} | |||
| > | |||
| {scanMode === "wedge" ? t("scanning") : t("scanAgain")} | |||
| </Button> | |||
| {lastLotNo && ( | |||
| <Chip | |||
| label={lastLotNo} | |||
| color="primary" | |||
| variant="outlined" | |||
| sx={{ ml: { sm: "auto" } }} | |||
| /> | |||
| )} | |||
| </Stack> | |||
| {scanError && ( | |||
| <Alert severity="warning" onClose={() => setScanError(null)}> | |||
| {scanError} | |||
| </Alert> | |||
| )} | |||
| {scanMode === "wedge" && ( | |||
| <Alert severity="info" onClose={stopWedgeScan}> | |||
| {t("scanReady")} | |||
| </Alert> | |||
| )} | |||
| <Typography variant="subtitle2">{t("manualSearch")}</Typography> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={1}> | |||
| <TextField | |||
| size="small" | |||
| label={t("itemCode")} | |||
| value={itemCode} | |||
| onChange={(e) => setItemCode(e.target.value)} | |||
| disabled={loading} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("lotNo")} | |||
| value={lotNo} | |||
| onChange={(e) => setLotNo(e.target.value)} | |||
| disabled={loading} | |||
| fullWidth | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<SearchIcon />} | |||
| onClick={handleManualSearch} | |||
| disabled={loading || !itemCode.trim() || !lotNo.trim()} | |||
| sx={{ minWidth: 120 }} | |||
| > | |||
| {loading ? t("searching") : t("search")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingScanBar; | |||
| @@ -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<Props> = ({ data, compiledGraph, onTrace }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const [tab, setTab] = useState(0); | |||
| const { bomTrace, joPrelude, lot } = data; | |||
| const tr = useMemo(() => createTraceLabelTranslator(t), [t]); | |||
| const presentationRows = useMemo( | |||
| () => buildTracePresentationRows(compiledGraph.nodes, data), | |||
| [compiledGraph.nodes, data], | |||
| ); | |||
| const multiLocation = hasMultipleLocations(data); | |||
| const mergedOrigins = useMemo(() => mergeScopedOrigins(data), [data]); | |||
| const mergedQc = useMemo(() => mergeScopedQcResults(data), [data]); | |||
| const mergedOutbound = useMemo(() => mergeScopedOutboundUsage(data), [data]); | |||
| const mergedStockTake = useMemo(() => mergeScopedStockTakeEvents(data), [data]); | |||
| const mergedAdjustments = useMemo(() => mergeScopedAdjustments(data), [data]); | |||
| const mergedTransfers = useMemo(() => mergeScopedTransfers(data), [data]); | |||
| const stockUom = lot.uom; | |||
| const tabKeys = useMemo(() => { | |||
| const keys = [ | |||
| "origins", | |||
| "qc", | |||
| "outbound", | |||
| "stockTake", | |||
| "adjustments", | |||
| "transfers", | |||
| "bom", | |||
| ] as const; | |||
| if (joPrelude) { | |||
| return [...keys, "joPick"] as const; | |||
| } | |||
| return keys; | |||
| }, [joPrelude]); | |||
| const activeTab = tabKeys[tab] ?? tabKeys[0]; | |||
| const bomDirectionLabel = | |||
| bomTrace.direction === "FINISHED_GOOD" | |||
| ? t("bomDirectionFG") | |||
| : bomTrace.direction === "MATERIAL" | |||
| ? t("bomDirectionMaterial") | |||
| : t("bomDirectionUnknown"); | |||
| const useJoMaterialInputs = joPrelude != null && joPrelude.materialInputs.length > 0; | |||
| const checkboxCellFromStatus = ( | |||
| status?: string | null, | |||
| opts?: { checkedWhen?: Array<string>; rejectedWhen?: Array<string> }, | |||
| ): React.ReactNode => { | |||
| const s = String(status || "").trim().toLowerCase(); | |||
| const checkedWhen = opts?.checkedWhen ?? []; | |||
| const rejectedWhen = opts?.rejectedWhen ?? []; | |||
| const isRejected = rejectedWhen.includes(s); | |||
| const isChecked = checkedWhen.includes(s); | |||
| if (isRejected) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| readOnly | |||
| size="small" | |||
| sx={{ color: "error.main", "&.Mui-checked": { color: "error.main" } }} | |||
| /> | |||
| ); | |||
| } | |||
| if (isChecked) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| readOnly | |||
| size="small" | |||
| sx={{ color: "success.main", "&.Mui-checked": { color: "success.main" } }} | |||
| /> | |||
| ); | |||
| } | |||
| return <Checkbox checked={false} disabled readOnly size="small" />; | |||
| }; | |||
| const originCols = useMemo((): FilterableColumnDef<(typeof mergedOrigins)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedOrigins)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (o) => o.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => tr.refType(o.type), | |||
| }, | |||
| { | |||
| key: "ref", | |||
| label: t("ref"), | |||
| value: (o) => o.refCode || "—", | |||
| cell: (o) => | |||
| o.type === "PO" ? ( | |||
| <ItemTracingDocLink kind="po" code={o.refCode} id={o.refId} /> | |||
| ) : o.type === "JO" ? ( | |||
| <ItemTracingDocLink kind="jo" code={o.refCode} id={o.refId} /> | |||
| ) : ( | |||
| o.refCode || "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (o) => [o.supplierCode, o.supplierName].filter(Boolean).join(" "), | |||
| }, | |||
| { key: "dnNo", label: t("dnNo"), value: (o) => o.dnNo || "—" }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => formatQty(o.acceptedQty, stockUom), | |||
| }, | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (o) => tr.stockInStatus(o.status), | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (o) => o.receiptDate ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, tr, stockUom]); | |||
| const qcCols = useMemo((): FilterableColumnDef<(typeof mergedQc)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedQc)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (q) => q.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (q) => (q.qcPassed ? t("qcPassed") : t("qcFailed")), | |||
| cell: (q) => ( | |||
| <Chip | |||
| size="small" | |||
| color={q.qcPassed ? "success" : "error"} | |||
| label={q.qcPassed ? t("qcPassed") : t("qcFailed")} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "criteria", | |||
| label: t("detailQcCriteria"), | |||
| value: (q) => qcItemLabel(q) || "—", | |||
| }, | |||
| { | |||
| key: "acceptedQty", | |||
| label: t("acceptedQty"), | |||
| align: "right", | |||
| value: (q) => formatQty(q.acceptedQty, stockUom), | |||
| }, | |||
| { | |||
| key: "failQty", | |||
| label: t("failQty"), | |||
| align: "right", | |||
| value: (q) => formatQty(q.failQty, stockUom), | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (q) => q.remarks?.trim() || "", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (q) => q.handledBy || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (q) => q.created ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom]); | |||
| const outboundCols = useMemo((): FilterableColumnDef<(typeof mergedOutbound)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedOutbound)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (u) => u.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (u) => tr.usageType(u.usageType), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (u) => formatQty(u.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (u) => u.pickOrderCode || u.consoCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink | |||
| kind="pick" | |||
| code={u.pickOrderCode} | |||
| id={u.pickOrderId} | |||
| consoCode={u.consoCode} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (u) => u.jobOrderCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "deliveryOrder", | |||
| label: t("deliveryOrder"), | |||
| value: (u) => u.deliveryOrderCode || "—", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (u) => u.handler || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (u) => u.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, tr, stockUom]); | |||
| const stockTakeCols = useMemo((): FilterableColumnDef<(typeof mergedStockTake)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedStockTake)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (e) => e.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { key: "ref", label: t("ref"), value: (e) => e.stockTakeCode }, | |||
| { | |||
| key: "round", | |||
| label: t("detailStockTakeRound"), | |||
| value: (e) => | |||
| e.stockTakeRoundName?.trim() || | |||
| (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : "—"), | |||
| }, | |||
| { | |||
| key: "section", | |||
| label: t("detailStockTakeSection"), | |||
| value: (e) => e.stockTakeSection?.trim() || "—", | |||
| }, | |||
| { | |||
| key: "location", | |||
| label: t("detailLocation"), | |||
| value: (e) => e.warehouseCode?.trim() || "—", | |||
| }, | |||
| { | |||
| key: "lotNo", | |||
| label: t("lotNo"), | |||
| value: (e) => e.lotNo?.trim() || data.lot.lotNo || "—", | |||
| }, | |||
| { | |||
| key: "before", | |||
| label: t("before"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| resolveStockTakeBookQty(e.recordDetail, e.beforeQty), | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "after", | |||
| label: t("after"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| resolveStockTakeAcceptedQty(e.recordDetail, e.afterQty), | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "variance", | |||
| label: t("variance"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| e.recordDetail?.varianceQty != null | |||
| ? e.recordDetail.varianceQty | |||
| : e.varianceQty, | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "approver", | |||
| label: t("approver"), | |||
| value: (e) => e.approver || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (e) => e.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom, data.lot.lotNo]); | |||
| const adjustmentCols = useMemo((): FilterableColumnDef<(typeof mergedAdjustments)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedAdjustments)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (a) => a.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (a) => a.adjustmentType, | |||
| }, | |||
| { | |||
| key: "direction", | |||
| label: `${t("directionIn")}/${t("directionOut")}`, | |||
| value: (a) => a.direction, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (a) => formatQty(a.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "ref", | |||
| label: t("ref"), | |||
| value: (a) => a.refCode || "—", | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (a) => a.reason?.trim() || "", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (a) => a.handledBy || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (a) => a.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom]); | |||
| const transferCols = useMemo((): FilterableColumnDef<(typeof mergedTransfers)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedTransfers)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (row) => row.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "from", | |||
| label: t("from"), | |||
| value: (row) => row.fromWarehouse || "—", | |||
| }, | |||
| { | |||
| key: "to", | |||
| label: t("to"), | |||
| value: (row) => row.toWarehouse || "—", | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (row) => formatQty(row.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "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<typeof joPrelude>["materialInputs"][number]; | |||
| type BomUpstream = (typeof bomTrace.upstream)[number]; | |||
| const bomUpstreamJoCols = useMemo((): FilterableColumnDef<JoMaterialInput>[] => [ | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (m) => m.jobOrderCode || "—", | |||
| cell: (m) => ( | |||
| <ItemTracingDocLink kind="jo" code={m.jobOrderCode} id={m.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { key: "material", label: t("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 ? ( | |||
| <ItemTracingDocLink | |||
| kind="pick" | |||
| code={m.pickOrderCode} | |||
| id={m.pickOrderId} | |||
| consoCode={m.consoCode} | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "pickedAt", | |||
| label: t("pickedAt"), | |||
| value: (m) => m.pickedAt ?? "—", | |||
| }, | |||
| { | |||
| key: "materialQty", | |||
| label: t("materialQty"), | |||
| align: "right", | |||
| value: (m) => formatNum(Number(m.materialQty)), | |||
| }, | |||
| { | |||
| key: "qtyPerUnit", | |||
| label: t("qtyPerUnit"), | |||
| align: "right", | |||
| value: (m) => | |||
| m.bomQtyPerUnit != null ? formatNum(Number(m.bomQtyPerUnit)) : "—", | |||
| }, | |||
| { | |||
| key: "uom", | |||
| label: t("uom"), | |||
| value: (m) => m.materialUom?.trim() || "", | |||
| }, | |||
| { | |||
| key: "processingStatus", | |||
| label: t("processingStatus"), | |||
| align: "center", | |||
| value: (m) => tr.processingStatus(m.processingStatus), | |||
| cell: (m) => | |||
| checkboxCellFromStatus(m.processingStatus, { | |||
| checkedWhen: ["completed"], | |||
| rejectedWhen: ["rejected"], | |||
| }), | |||
| }, | |||
| { | |||
| key: "matchStatus", | |||
| label: t("matchStatus"), | |||
| align: "center", | |||
| value: (m) => tr.matchStatus(m.matchStatus), | |||
| cell: (m) => checkboxCellFromStatus(m.matchStatus, { checkedWhen: ["completed"] }), | |||
| }, | |||
| { | |||
| key: "trace", | |||
| label: "", | |||
| value: () => "", | |||
| cell: (m) => | |||
| m.materialLotNo && onTrace ? ( | |||
| <MuiLink | |||
| component="button" | |||
| type="button" | |||
| variant="body2" | |||
| underline="hover" | |||
| onClick={() => | |||
| onTrace({ | |||
| lotNo: m.materialLotNo, | |||
| itemCode: m.materialItemCode, | |||
| }) | |||
| } | |||
| > | |||
| {t("traceMaterialLot")} | |||
| </MuiLink> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| ], [t, tr, stockUom, onTrace]); | |||
| const bomUpstreamCols = useMemo((): FilterableColumnDef<BomUpstream>[] => [ | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (u) => u.jobOrderCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { key: "material", label: t("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) => ( | |||
| <ItemTracingDocLink kind="jo" code={d.jobOrderCode} id={d.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { 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<JoPickRow>[] => [ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (row) => row.pickOrderCode || row.consoCode || "—", | |||
| cell: (row) => | |||
| row.pickOrderCode ? ( | |||
| <ItemTracingDocLink | |||
| kind="jodetail" | |||
| code={row.pickOrderCode} | |||
| id={row.pickOrderId} | |||
| consoCode={row.consoCode || row.pickOrderCode} | |||
| targetDate={normalizeTargetDateForLink( | |||
| row.targetDate || row.pickedAt, | |||
| )} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { key: "material", label: t("material"), value: (row) => row.materialItemCode }, | |||
| { | |||
| key: "lotNo", | |||
| label: t("lotNo"), | |||
| value: (row) => row.materialLotNo || "—", | |||
| cell: (row) => | |||
| row.materialLotNo ? ( | |||
| <ItemTracingLotTraceLink | |||
| label={row.materialLotNo} | |||
| lotNo={row.materialLotNo} | |||
| itemCode={row.materialItemCode} | |||
| variant="body2" | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "pickedQty", | |||
| label: t("pickedQty"), | |||
| align: "right", | |||
| value: (row) => formatQty(row.qty, row.uom || stockUom), | |||
| }, | |||
| { | |||
| key: "assignedStep", | |||
| label: t("detailAssignedStep"), | |||
| value: (row) => row.assignedStepName || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (row) => row.pickedAt ?? "—", | |||
| }, | |||
| ], [t, stockUom]); | |||
| type JoPickLine = NonNullable<typeof joPrelude>["pickOrders"][number]["lines"][number]; | |||
| const joPickLineCols = useMemo((): FilterableColumnDef<JoPickLine>[] => [ | |||
| { | |||
| key: "material", | |||
| label: t("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 ( | |||
| <Paper variant="outlined"> | |||
| <Tabs | |||
| value={tab} | |||
| onChange={(_, v) => setTab(v)} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| > | |||
| <Tab label={t("tabOrigins")} /> | |||
| <Tab label={t("tabQc")} /> | |||
| <Tab label={t("tabOutbound")} /> | |||
| <Tab label={t("tabStockTake")} /> | |||
| <Tab label={t("tabAdjustments")} /> | |||
| <Tab label={t("tabTransfers")} /> | |||
| <Tab label={t("tabBom")} /> | |||
| {joPrelude && <Tab label={t("tabJoPick")} />} | |||
| </Tabs> | |||
| <Box sx={{ p: 2, overflowX: "auto" }}> | |||
| {multiLocation && ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("sectionsMultiLocationHint")} | |||
| </Typography> | |||
| )} | |||
| {activeTab === "origins" && ( | |||
| <FilterableDataTable | |||
| rows={mergedOrigins} | |||
| columns={originCols} | |||
| getRowKey={(o) => `${o.inventoryLotId}-${o.stockInLineId}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "qc" && ( | |||
| <FilterableDataTable | |||
| rows={mergedQc} | |||
| columns={qcCols} | |||
| getRowKey={(q) => q.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "outbound" && ( | |||
| <FilterableDataTable | |||
| rows={mergedOutbound} | |||
| columns={outboundCols} | |||
| getRowKey={(u) => `${u.inventoryLotId}-${u.stockOutLineId}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "stockTake" && ( | |||
| <FilterableDataTable | |||
| rows={mergedStockTake} | |||
| columns={stockTakeCols} | |||
| getRowKey={(e) => e.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "adjustments" && ( | |||
| <FilterableDataTable | |||
| rows={mergedAdjustments} | |||
| columns={adjustmentCols} | |||
| getRowKey={(a) => a.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "transfers" && ( | |||
| <FilterableDataTable | |||
| rows={mergedTransfers} | |||
| columns={transferCols} | |||
| getRowKey={(row) => row.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "bom" && ( | |||
| <Box> | |||
| <Typography variant="body2" color="text.secondary" gutterBottom> | |||
| {t("bomDirection")}: <strong>{bomDirectionLabel}</strong> | |||
| </Typography> | |||
| <Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}> | |||
| {t("bomUpstream")} | |||
| </Typography> | |||
| <Box sx={{ mb: 2 }}> | |||
| {useJoMaterialInputs ? ( | |||
| <FilterableDataTable | |||
| rows={joPrelude!.materialInputs} | |||
| columns={bomUpstreamJoCols} | |||
| getRowKey={(_, i) => `up-jo-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| ) : ( | |||
| <FilterableDataTable | |||
| rows={bomTrace.upstream} | |||
| columns={bomUpstreamCols} | |||
| getRowKey={(_, i) => `up-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| </Box> | |||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | |||
| {t("bomDownstream")} | |||
| </Typography> | |||
| <Box sx={{ mb: 2 }}> | |||
| <FilterableDataTable | |||
| rows={bomTrace.downstream} | |||
| columns={bomDownstreamCols} | |||
| getRowKey={(_, i) => `down-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | |||
| {t("bomRecipe")} | |||
| </Typography> | |||
| <FilterableDataTable | |||
| rows={bomTrace.bomRecipe} | |||
| columns={bomRecipeCols} | |||
| getRowKey={(_, i) => `recipe-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| )} | |||
| {activeTab === "joPick" && joPrelude && ( | |||
| <Box> | |||
| {presentationRows.joPicks.length > 0 ? ( | |||
| <FilterableDataTable | |||
| rows={presentationRows.joPicks} | |||
| columns={joPickCols} | |||
| getRowKey={(row) => row.id} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| ) : joPrelude.pickOrders.length === 0 ? ( | |||
| <Typography color="text.secondary" variant="body2"> | |||
| {t("noRecords")} | |||
| </Typography> | |||
| ) : ( | |||
| joPrelude.pickOrders.map((po) => ( | |||
| <Box key={po.pickOrderId} sx={{ mb: 3 }}> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| <ItemTracingDocLink | |||
| kind="jodetail" | |||
| code={po.pickOrderCode} | |||
| id={po.pickOrderId} | |||
| consoCode={po.consoCode || po.pickOrderCode} | |||
| targetDate={normalizeTargetDateForLink(po.targetDate)} | |||
| openInNewTab | |||
| /> | |||
| {" · "} | |||
| <Chip | |||
| size="small" | |||
| label={po.status} | |||
| variant="outlined" | |||
| sx={{ ml: 0.5 }} | |||
| /> | |||
| </Typography> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| sx={{ mb: 1 }} | |||
| > | |||
| {[ | |||
| po.targetDate && `${t("targetDate")}: ${po.targetDate}`, | |||
| po.completeDate && `${t("completeDate")}: ${po.completeDate}`, | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · ")} | |||
| </Typography> | |||
| <FilterableDataTable | |||
| rows={po.lines} | |||
| columns={joPickLineCols} | |||
| getRowKey={(line) => String(line.pickOrderLineId)} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| )) | |||
| )} | |||
| </Box> | |||
| )} | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingSections; | |||
| @@ -0,0 +1,100 @@ | |||
| "use client"; | |||
| import { Box, Typography } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import type { StockTakeLifecycleStage } from "./traceStockTakeUtils"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| type Props = { | |||
| stages: StockTakeLifecycleStage[]; | |||
| uom?: string; | |||
| }; | |||
| /** Vertical timeline of stock-take progression (first count → approve). */ | |||
| const ItemTracingStockTakeLifecycle: React.FC<Props> = ({ stages, uom = "" }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| if (!stages.length) return null; | |||
| return ( | |||
| <Box sx={{ display: "flex", flexDirection: "column" }}> | |||
| {stages.map((stage, idx) => { | |||
| const isLast = idx === stages.length - 1; | |||
| return ( | |||
| <Box key={stage.key} sx={{ display: "flex", gap: 1 }}> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| alignItems: "center", | |||
| width: 12, | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| width: 10, | |||
| height: 10, | |||
| borderRadius: "50%", | |||
| bgcolor: stage.isActive ? "success.main" : "grey.400", | |||
| flexShrink: 0, | |||
| mt: 0.35, | |||
| zIndex: 1, | |||
| }} | |||
| /> | |||
| {!isLast && ( | |||
| <Box | |||
| sx={{ | |||
| width: 2, | |||
| flex: 1, | |||
| minHeight: 22, | |||
| bgcolor: | |||
| stage.isActive && stages[idx + 1]?.isActive ? "success.main" : "grey.300", | |||
| my: -0.5, | |||
| }} | |||
| /> | |||
| )} | |||
| </Box> | |||
| <Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 1 }}> | |||
| <Typography | |||
| variant="caption" | |||
| fontWeight={600} | |||
| display="block" | |||
| noWrap | |||
| color={stage.isActive ? "text.primary" : "text.disabled"} | |||
| > | |||
| {t(stage.label)} | |||
| </Typography> | |||
| {stage.isActive ? ( | |||
| <Typography variant="caption" color="text.secondary" display="block"> | |||
| {[ | |||
| stage.qty != null | |||
| ? stage.key === "round-created" | |||
| ? `${t("stockTakeStageBookQty")}: ${formatQty(stage.qty, uom)}` | |||
| : formatQty(stage.qty, uom) | |||
| : null, | |||
| stage.key === "accepted" && stage.badQty != null | |||
| ? `${t("variance")}: ${formatQty(stage.badQty, uom)}` | |||
| : stage.badQty != null && stage.badQty > 0 | |||
| ? `${t("unqualifiedQty")}: ${formatQty(stage.badQty, uom)}` | |||
| : null, | |||
| stage.handler ?? null, | |||
| stage.timestamp ?? null, | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · ") || "—"} | |||
| </Typography> | |||
| ) : ( | |||
| <Typography variant="caption" color="text.disabled" display="block"> | |||
| — | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| })} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default ItemTracingStockTakeLifecycle; | |||
| @@ -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<Props> = ({ | |||
| data, | |||
| onFocusWarehouse, | |||
| onExportExcel, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const tr = useMemo(() => createTraceLabelTranslator(t), [t]); | |||
| const { lot, warehouseLines, joPrelude, alternateLocations, locationBlocks } = | |||
| data; | |||
| const combinedWarehouseRows = useMemo((): SummaryWarehouseRow[] => { | |||
| const primary = warehouseLines.map((w) => ({ | |||
| key: `primary-${w.inventoryLotLineId}`, | |||
| inventoryLotId: lot.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| inQty: w.inQty, | |||
| outQty: w.outQty, | |||
| availableQty: w.availableQty, | |||
| status: w.status, | |||
| })); | |||
| const fromBlocks = (locationBlocks ?? []).flatMap((block) => | |||
| block.warehouseLines.map((w) => ({ | |||
| key: `alt-${block.inventoryLotId}-${w.inventoryLotLineId}`, | |||
| inventoryLotId: block.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| inQty: w.inQty, | |||
| outQty: w.outQty, | |||
| availableQty: w.availableQty, | |||
| status: w.status, | |||
| })), | |||
| ); | |||
| if (fromBlocks.length > 0) { | |||
| return [...primary, ...fromBlocks]; | |||
| } | |||
| // Fallback when location blocks are absent but alternateLocations exist. | |||
| const fromAlts = alternateLocations.map((loc) => ({ | |||
| key: `alt-loc-${loc.inventoryLotId}-${loc.inventoryLotLineId}`, | |||
| inventoryLotId: loc.inventoryLotId, | |||
| warehouseCode: loc.warehouseCode, | |||
| inQty: 0, | |||
| outQty: 0, | |||
| availableQty: loc.availableQty, | |||
| status: "", | |||
| })); | |||
| return [...primary, ...fromAlts]; | |||
| }, [ | |||
| warehouseLines, | |||
| locationBlocks, | |||
| alternateLocations, | |||
| lot.inventoryLotId, | |||
| ]); | |||
| const totalAvailable = combinedWarehouseRows.reduce( | |||
| (s, w) => s + (w.availableQty ?? 0), | |||
| 0, | |||
| ); | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2.5 }}> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| justifyContent="space-between" | |||
| spacing={1} | |||
| > | |||
| <Typography variant="overline" color="text.secondary"> | |||
| {t("summary")} | |||
| </Typography> | |||
| {onExportExcel && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={onExportExcel} | |||
| title={t("exportExcelTooltip")} | |||
| > | |||
| {t("exportExcel")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| <Stack | |||
| direction={{ xs: "column", md: "row" }} | |||
| spacing={2} | |||
| alignItems={{ md: "center" }} | |||
| sx={{ mt: 1 }} | |||
| > | |||
| <Box sx={{ flex: 1 }}> | |||
| <Typography variant="h5" fontWeight={700}> | |||
| {lot.lotNo || "—"} | |||
| </Typography> | |||
| <Typography variant="subtitle1" color="text.secondary"> | |||
| {lot.itemCode} · {lot.itemName} | |||
| </Typography> | |||
| </Box> | |||
| <Chip | |||
| label={`${t("totalAvailable")}: ${formatQty( | |||
| totalAvailable, | |||
| lot.uom, | |||
| )}`} | |||
| color="primary" | |||
| variant="outlined" | |||
| /> | |||
| </Stack> | |||
| <Grid container spacing={2} sx={{ mt: 2 }}> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("expiryDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.expiryDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("productionDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.productionDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("stockInDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.stockInDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("uom")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.uom || "—"}</Typography> | |||
| </Grid> | |||
| </Grid> | |||
| {joPrelude && ( | |||
| <Box sx={{ mt: 2, p: 1.5, bgcolor: "action.hover", borderRadius: 1 }}> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| {t("joContext")} | |||
| </Typography> | |||
| <Grid container spacing={1}> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("jobOrder")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {joPrelude.jobOrder.jobOrderId != null ? ( | |||
| <ItemTracingDocLink | |||
| kind="jo" | |||
| id={joPrelude.jobOrder.jobOrderId} | |||
| code={joPrelude.jobOrder.jobOrderCode} | |||
| /> | |||
| ) : ( | |||
| joPrelude.jobOrder.jobOrderCode || "—" | |||
| )} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("status")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {tr.joStatus(joPrelude.jobOrder.status)} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("planStart")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {joPrelude.jobOrder.planStart ?? "—"} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("plannedQty")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)} | |||
| </Typography> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| )} | |||
| {combinedWarehouseRows.length > 0 && ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Table size="small" sx={{ tableLayout: "auto" }}> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("warehouse")}</TableCell> | |||
| <TableCell align="right">{t("inQty")}</TableCell> | |||
| <TableCell align="right">{t("outQty")}</TableCell> | |||
| <TableCell align="right">{t("available")}</TableCell> | |||
| <TableCell>{t("status")}</TableCell> | |||
| {onFocusWarehouse && ( | |||
| <TableCell align="right">{t("action")}</TableCell> | |||
| )} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {combinedWarehouseRows.map((w) => ( | |||
| <TableRow key={w.key}> | |||
| <TableCell>{w.warehouseCode || "—"}</TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.inQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.outQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.availableQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell> | |||
| {w.status ? ( | |||
| <Chip | |||
| size="small" | |||
| label={tr.lotLineStatus(w.status)} | |||
| variant="outlined" | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| )} | |||
| </TableCell> | |||
| {onFocusWarehouse && ( | |||
| <TableCell align="right"> | |||
| {w.warehouseCode?.trim() ? ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => | |||
| onFocusWarehouse({ | |||
| inventoryLotId: w.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| }) | |||
| } | |||
| > | |||
| {t("focusWarehouseInGraph")} | |||
| </Button> | |||
| ) : ( | |||
| "—" | |||
| )} | |||
| </TableCell> | |||
| )} | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| )} | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingSummary; | |||
| @@ -0,0 +1,58 @@ | |||
| "use client"; | |||
| import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; | |||
| import { buildTraceFlowCorridorPath } from "./traceFlowEdgeLayout"; | |||
| export type TraceFlowEdgeData = { | |||
| offset?: number; | |||
| pathMode?: "smooth" | "corridor"; | |||
| corridorY?: number; | |||
| corridorEntryOffset?: number; | |||
| corridorExitOffset?: number; | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| export const TraceFlowEdge = ({ | |||
| id, | |||
| sourceX, | |||
| sourceY, | |||
| targetX, | |||
| targetY, | |||
| sourcePosition, | |||
| targetPosition, | |||
| style, | |||
| markerEnd, | |||
| data, | |||
| }: EdgeProps) => { | |||
| const edgeData = data as TraceFlowEdgeData | undefined; | |||
| const offset = edgeData?.offset ?? 0; | |||
| const path = | |||
| edgeData?.pathMode === "corridor" && edgeData.corridorY != null | |||
| ? buildTraceFlowCorridorPath( | |||
| sourceX, | |||
| sourceY, | |||
| targetX, | |||
| targetY, | |||
| edgeData.corridorY, | |||
| edgeData.corridorEntryOffset, | |||
| edgeData.corridorExitOffset, | |||
| edgeData.corridorBranchOffset, | |||
| ) | |||
| : getSmoothStepPath({ | |||
| sourceX, | |||
| sourceY, | |||
| sourcePosition, | |||
| targetX, | |||
| targetY, | |||
| targetPosition, | |||
| borderRadius: 8, | |||
| offset, | |||
| })[0]; | |||
| return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />; | |||
| }; | |||
| export const traceFlowEdgeTypes = { | |||
| traceFlow: TraceFlowEdge, | |||
| }; | |||
| @@ -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; | |||
| }) => ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={collapsed ? expandLabel : collapseLabel} | |||
| aria-expanded={!collapsed} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| onToggle?.(); | |||
| }} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| sx={{ | |||
| pointerEvents: "all", | |||
| p: 0.25, | |||
| ml: "auto", | |||
| flexShrink: 0, | |||
| color: "warning.dark", | |||
| }} | |||
| > | |||
| {collapsed ? ( | |||
| <ExpandMoreIcon fontSize="small" /> | |||
| ) : ( | |||
| <ExpandLessIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| ); | |||
| export const TraceFlowEventNode = memo(function TraceFlowEventNode({ | |||
| id, | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const { setNodes } = useReactFlow(); | |||
| const updateNodeInternals = useUpdateNodeInternals(); | |||
| const node = data.layoutNode; | |||
| const color = kindColor(node.kind); | |||
| const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC"; | |||
| const isStockTake = node.kind === "STOCK_TAKE"; | |||
| const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null; | |||
| const [lifecycleExpanded, setLifecycleExpanded] = useState(false); | |||
| const lifecycleStages = hasLifecycle | |||
| ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) | |||
| : []; | |||
| const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH; | |||
| useEffect(() => { | |||
| if (!hasLifecycle) return; | |||
| const nextH = lifecycleExpanded | |||
| ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA | |||
| : NODE_HEIGHT; | |||
| setNodes((nds) => | |||
| nds.map((n) => { | |||
| if (n.id !== id) return n; | |||
| return { | |||
| ...n, | |||
| height: nextH, | |||
| style: { ...(n.style ?? {}), width: nodeWidth, height: nextH }, | |||
| measured: { | |||
| width: n.measured?.width ?? n.width ?? nodeWidth, | |||
| height: nextH, | |||
| }, | |||
| }; | |||
| }), | |||
| ); | |||
| const raf = requestAnimationFrame(() => updateNodeInternals(id)); | |||
| return () => cancelAnimationFrame(raf); | |||
| }, [ | |||
| hasLifecycle, | |||
| lifecycleExpanded, | |||
| id, | |||
| nodeWidth, | |||
| setNodes, | |||
| updateNodeInternals, | |||
| ]); | |||
| const chipLabel = | |||
| node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim() | |||
| ? node.putawayStatusLabel | |||
| : isQc && node.qcTypeLabel?.trim() | |||
| ? node.qcTypeLabel | |||
| : t(kindLabelKey(node.kind)); | |||
| const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp"); | |||
| const isDoOut = node.kind === "DO_OUT"; | |||
| const doOutKindChipSx = { | |||
| height: data.compact ? 18 : 20, | |||
| "& .MuiChip-label": { | |||
| px: 0.75, | |||
| fontSize: data.compact ? "0.65rem" : "0.7rem", | |||
| fontWeight: 600, | |||
| }, | |||
| } as const; | |||
| const stopCardClick = (e: React.MouseEvent) => { | |||
| e.stopPropagation(); | |||
| }; | |||
| const doOutboundKindChips = | |||
| isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? ( | |||
| <> | |||
| {node.doOutboundIsExtra ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t("doOutboundExtra")} | |||
| color="secondary" | |||
| sx={doOutKindChipSx} | |||
| onClick={stopCardClick} | |||
| onMouseDown={stopCardClick} | |||
| /> | |||
| ) : null} | |||
| {node.doOutboundIsReplenish ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t("doOutboundReplenish")} | |||
| color="success" | |||
| sx={doOutKindChipSx} | |||
| onClick={stopCardClick} | |||
| onMouseDown={stopCardClick} | |||
| /> | |||
| ) : null} | |||
| </> | |||
| ) : null; | |||
| const titleContent = isDoOut ? ( | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| display: "inline-flex", | |||
| flexWrap: "wrap", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| maxWidth: "100%", | |||
| }} | |||
| > | |||
| {node.docLinkKind && node.refCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind} | |||
| code={node.refCode} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.refCode || "—" | |||
| )} | |||
| </Box> | |||
| ) : node.docLinkKind && node.refCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind} | |||
| code={node.refCode} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.title | |||
| ); | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const outgoingCount = data.outgoingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const nodeSelected = data.nodeSelected ?? false; | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| const activeCount = lifecycleStages.filter((s) => s.isActive).length; | |||
| const card = ( | |||
| <Paper | |||
| variant="outlined" | |||
| sx={{ | |||
| width: nodeWidth, | |||
| minWidth: nodeWidth, | |||
| maxWidth: nodeWidth, | |||
| height: NODE_HEIGHT, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| overflow: "hidden", | |||
| position: "relative", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : nodeSelected | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : isQc | |||
| ? `${color}.main` | |||
| : undefined, | |||
| borderWidth: searchFocused || searchMatch || nodeSelected ? 2 : isQc ? 2 : 1, | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| transition: "opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease", | |||
| boxShadow: searchFocused ? 8 : nodeSelected ? 6 : searchMatch ? 4 : undefined, | |||
| cursor: "pointer", | |||
| "&:hover": { boxShadow: searchFocused || searchMatch || nodeSelected ? undefined : 6 }, | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box sx={{ height: 4, bgcolor: `${color}.main`, flexShrink: 0 }} /> | |||
| <Box | |||
| sx={{ | |||
| p: data.compact ? 1 : 1.25, | |||
| pb: hasLifecycle ? (data.compact ? 0.5 : 0.75) : undefined, | |||
| flex: 1, | |||
| minHeight: 0, | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| flexWrap: "wrap", | |||
| gap: 0.5, | |||
| mb: 0.75, | |||
| }} | |||
| > | |||
| {isQc && ( | |||
| <Box | |||
| sx={{ | |||
| width: 8, | |||
| height: 8, | |||
| bgcolor: `${color}.main`, | |||
| transform: "rotate(45deg)", | |||
| flexShrink: 0, | |||
| }} | |||
| /> | |||
| )} | |||
| <Chip size="small" label={chipLabel} color={color} /> | |||
| {doOutboundKindChips} | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| lineHeight: 1.3, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| fontWeight: 700, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| ...(isDoOut | |||
| ? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5 } | |||
| : { | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }), | |||
| }} | |||
| title={typeof node.refCode === "string" && node.refCode.trim() ? node.refCode : node.title} | |||
| > | |||
| {titleContent} | |||
| </Box> | |||
| {node.subtitle?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.subtitle} | |||
| sx={{ | |||
| mt: 0.5, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {node.subtitle} | |||
| </Typography> | |||
| ) : null} | |||
| {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={node.meta?.trim() || node.traceItemCode} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("Item")}: {node.meta?.trim() || node.traceItemCode} | |||
| </Typography> | |||
| ) : null} | |||
| {node.qty != null && ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={ | |||
| node.kind === "ADJUSTMENT" | |||
| ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) | |||
| : formatQty(node.qty, node.uom) | |||
| } | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {node.kind === "PURCHASE" | |||
| ? t("detailOrderQty") | |||
| : node.kind === "STOCK_TAKE" | |||
| ? t("after") | |||
| : node.kind === "ADJUSTMENT" | |||
| ? t("variance") | |||
| : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL" | |||
| ? t("unqualifiedQty") | |||
| : node.kind === "PRODUCTION_STEP" | |||
| ? t("processOutputQty") | |||
| : t("qty")} | |||
| :{" "} | |||
| {node.kind === "ADJUSTMENT" | |||
| ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) | |||
| : formatQty(node.qty, node.uom)} | |||
| </Typography> | |||
| )} | |||
| {node.traceLotNo ? ( | |||
| <ItemTracingLotTraceLink | |||
| label={ | |||
| node.kind === "BYPRODUCT" | |||
| ? t("traceByproductLot") | |||
| : node.kind === "REPACK" | |||
| ? t("traceRepackLot") | |||
| : t("traceMaterialLot") | |||
| } | |||
| lotNo={node.traceLotNo} | |||
| itemCode={node.traceItemCode} | |||
| stopPropagation | |||
| sx={{ mt: 0.5, display: "inline-block" }} | |||
| /> | |||
| ) : null} | |||
| {node.warehouseCode?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.warehouseCode} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("warehouse")}: {node.warehouseCode} | |||
| </Typography> | |||
| ) : null} | |||
| {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && | |||
| node.processingStatusLabel?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.processingStatusLabel} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 1, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("processingStatus")}:{" "} | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| color: (() => { | |||
| const c = pickStatusValueColor(node.processingStatus); | |||
| return c === "default" ? "text.primary" : `${c}.main`; | |||
| })(), | |||
| fontWeight: 700, | |||
| }} | |||
| > | |||
| {node.processingStatusLabel} | |||
| </Box> | |||
| </Typography> | |||
| ) : null} | |||
| {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && | |||
| node.matchStatusLabel?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.matchStatusLabel} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 1, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("matchStatus")}:{" "} | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| color: (() => { | |||
| const c = pickStatusValueColor(node.matchStatus); | |||
| return c === "default" ? "text.primary" : `${c}.main`; | |||
| })(), | |||
| fontWeight: 700, | |||
| }} | |||
| > | |||
| {node.matchStatusLabel} | |||
| </Box> | |||
| </Typography> | |||
| ) : null} | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}> | |||
| {dateLabel} | |||
| </Typography> | |||
| </Box> | |||
| {hasLifecycle ? ( | |||
| <Box | |||
| sx={{ | |||
| flexShrink: 0, | |||
| borderTop: 1, | |||
| borderColor: "divider", | |||
| px: data.compact ? 0.75 : 1, | |||
| py: 0.25, | |||
| }} | |||
| > | |||
| <Box | |||
| role="button" | |||
| tabIndex={0} | |||
| aria-expanded={lifecycleExpanded} | |||
| aria-label={lifecycleExpanded ? t("stockTakeStageCollapse") : t("stockTakeStageExpand")} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| setLifecycleExpanded((v) => !v); | |||
| }} | |||
| onKeyDown={(e) => { | |||
| if (e.key === "Enter" || e.key === " ") { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| setLifecycleExpanded((v) => !v); | |||
| } | |||
| }} | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "space-between", | |||
| gap: 0.5, | |||
| width: "100%", | |||
| py: 0.25, | |||
| px: 0.5, | |||
| borderRadius: 1, | |||
| cursor: "pointer", | |||
| userSelect: "none", | |||
| "&:hover": { bgcolor: "action.hover" }, | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} color="secondary.main" noWrap> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} | |||
| </Typography> | |||
| {lifecycleExpanded ? ( | |||
| <ExpandLessIcon fontSize="small" color="secondary" /> | |||
| ) : ( | |||
| <ExpandMoreIcon fontSize="small" color="secondary" /> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ) : null} | |||
| {outgoingCount > 0 && | |||
| Array.from({ length: outgoingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`out-${i}`} | |||
| id={`out-${i}`} | |||
| type="source" | |||
| position={Position.Right} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, outgoingCount), | |||
| right: 0, | |||
| }} | |||
| /> | |||
| ))} | |||
| </Paper> | |||
| ); | |||
| // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body). | |||
| const lifecyclePanel = | |||
| hasLifecycle && lifecycleExpanded ? ( | |||
| <Paper | |||
| variant="outlined" | |||
| className="nowheel nodrag nopan" | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onWheel={(e) => e.stopPropagation()} | |||
| sx={{ | |||
| width: nodeWidth, | |||
| maxWidth: nodeWidth, | |||
| mt: 0.75, | |||
| p: 1.25, | |||
| boxShadow: 4, | |||
| borderColor: "secondary.main", | |||
| borderWidth: 1.5, | |||
| maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24, | |||
| overflow: "auto", | |||
| overscrollBehavior: "contain", | |||
| bgcolor: "background.paper", | |||
| pointerEvents: "all", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} color="secondary.main" sx={{ mb: 1, display: "block" }}> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} | |||
| </Typography> | |||
| <Divider sx={{ mb: 1 }} /> | |||
| <ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} /> | |||
| </Paper> | |||
| ) : null; | |||
| const wrapped = ( | |||
| <Box | |||
| sx={{ | |||
| width: nodeWidth, | |||
| height: lifecycleExpanded | |||
| ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA | |||
| : NODE_HEIGHT, | |||
| overflow: "visible", | |||
| }} | |||
| > | |||
| {card} | |||
| {lifecyclePanel} | |||
| </Box> | |||
| ); | |||
| if (node.meta) { | |||
| return ( | |||
| <Tooltip title={node.meta} arrow placement="top"> | |||
| {wrapped} | |||
| </Tooltip> | |||
| ); | |||
| } | |||
| return wrapped; | |||
| }); | |||
| export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width: PHASE_LABEL_INNER, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| px: 0.75, | |||
| py: 1, | |||
| borderLeft: `4px solid ${data.phaseColor ?? "#757575"}`, | |||
| backgroundImage: `linear-gradient(${data.phaseColor ?? "#757575"}14, ${data.phaseColor ?? "#757575"}14)`, | |||
| borderRadius: 1, | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="caption" | |||
| fontWeight={700} | |||
| sx={{ | |||
| writingMode: "vertical-rl", | |||
| textOrientation: "mixed", | |||
| color: data.phaseColor ?? "#757575", | |||
| letterSpacing: 0.5, | |||
| }} | |||
| > | |||
| {data.phaseLabel} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| return ( | |||
| <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ width: 80, textAlign: "center" }}> | |||
| {data.dateLabel} | |||
| </Typography> | |||
| ); | |||
| }); | |||
| export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const node = data.layoutNode; | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const collapsed = data.groupCollapsed === true; | |||
| const width = node.groupBoxWidth ?? NODE_WIDTH; | |||
| const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width, | |||
| height, | |||
| border: 2, | |||
| borderStyle: "dashed", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : "warning.light", | |||
| borderRadius: 1, | |||
| bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45), | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, | |||
| position: "relative", | |||
| overflow: "hidden", | |||
| pointerEvents: "none", | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box | |||
| sx={{ | |||
| height: DO_GROUP_HEADER, | |||
| px: 1.25, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| overflow: "hidden", | |||
| bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35), | |||
| borderBottom: collapsed ? 0 : 1, | |||
| borderColor: "warning.light", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} noWrap sx={{ color: "warning.dark", minWidth: 0 }}> | |||
| {node.title} | |||
| </Typography> | |||
| {node.groupTotalQty != null ? ( | |||
| <Typography variant="caption" fontWeight={600} noWrap sx={{ color: "warning.dark", flexShrink: 0 }}> | |||
| {t("flowDoGroupTotalQty", { | |||
| qtyLabel: formatQty(node.groupTotalQty, node.groupUom), | |||
| })} | |||
| </Typography> | |||
| ) : null} | |||
| <GroupCollapseButton | |||
| collapsed={collapsed} | |||
| onToggle={data.onToggleGroupCollapse} | |||
| collapseLabel={t("flowGroupCollapse")} | |||
| expandLabel={t("flowGroupExpand")} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const node = data.layoutNode; | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const outgoingCount = data.outgoingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const collapsed = data.groupCollapsed === true; | |||
| const width = node.groupBoxWidth ?? NODE_WIDTH; | |||
| const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width, | |||
| height, | |||
| border: 2, | |||
| borderStyle: "dashed", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : "warning.light", | |||
| borderRadius: 1, | |||
| bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45), | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, | |||
| position: "relative", | |||
| overflow: "hidden", | |||
| pointerEvents: "none", | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box | |||
| sx={{ | |||
| height: DO_GROUP_HEADER, | |||
| px: 1.25, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35), | |||
| borderBottom: collapsed ? 0 : 1, | |||
| borderColor: "warning.light", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} noWrap sx={{ flex: 1, color: "warning.dark", minWidth: 0 }}> | |||
| {node.title} | |||
| </Typography> | |||
| <GroupCollapseButton | |||
| collapsed={collapsed} | |||
| onToggle={data.onToggleGroupCollapse} | |||
| collapseLabel={t("flowGroupCollapse")} | |||
| expandLabel={t("flowGroupExpand")} | |||
| /> | |||
| </Box> | |||
| {outgoingCount > 0 && | |||
| Array.from({ length: outgoingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`out-${i}`} | |||
| id={`out-${i}`} | |||
| type="source" | |||
| position={Position.Right} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, outgoingCount), | |||
| right: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const traceFlowNodeTypes = { | |||
| traceEvent: TraceFlowEventNode, | |||
| doGroup: TraceFlowDoGroupNode, | |||
| pickGroup: TraceFlowPickGroupNode, | |||
| phaseLabel: TraceFlowPhaseLabelNode, | |||
| dateHeader: TraceFlowDateHeaderNode, | |||
| }; | |||
| @@ -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)); | |||
| }; | |||
| @@ -0,0 +1,632 @@ | |||
| import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { | |||
| buildProductionGraphNodes, | |||
| ProductionGraphLabels, | |||
| } from "./buildProductionGraphNodes"; | |||
| import { | |||
| groupQcResultsBySession, | |||
| } from "./traceLabelUtils"; | |||
| import { formatQty, formatSignedQty } from "./traceQtyUtils"; | |||
| import { | |||
| createMaterialPickNode, | |||
| docLinkFromOriginType, | |||
| docLinkFromRefType, | |||
| field, | |||
| fieldIf, | |||
| detailsOf, | |||
| isJoProducedMaterial, | |||
| parseSortKey, | |||
| } from "./traceNodeFactory"; | |||
| import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils"; | |||
| import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils"; | |||
| export interface JoPreludeGraphLabels extends TraceGraphDetailLabels { | |||
| nodeMaterialIn: string; | |||
| nodeMaterialPick: string; | |||
| nodePurchase: string; | |||
| nodeReceipt: string; | |||
| nodeJoCreated: string; | |||
| pickOrder: string; | |||
| } | |||
| type LotPickRef = { | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| }; | |||
| type PreludeBuildContext = { | |||
| seenPurchaseLines: Set<number>; | |||
| seenInboundLots: Set<number>; | |||
| seenReceiptLots: Set<number>; | |||
| seenQcKeys: Set<string>; | |||
| seenPutawayKeys: Set<string>; | |||
| pickOrderTargetDateMap: Map<string, string>; | |||
| seq: number; | |||
| }; | |||
| const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`; | |||
| const mergeLotPickMaps = ( | |||
| target: Map<string, LotPickRef[]>, | |||
| source: Map<string, LotPickRef[]>, | |||
| ): void => { | |||
| source.forEach((picks, key) => { | |||
| const list = target.get(key) ?? []; | |||
| picks.forEach((p) => { | |||
| if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) { | |||
| list.push(p); | |||
| } | |||
| }); | |||
| if (list.length) target.set(key, list); | |||
| }); | |||
| }; | |||
| const buildLotPickOrderMap = ( | |||
| materialInputs: ItemLotTraceJoPrelude["materialInputs"], | |||
| ): Map<string, LotPickRef[]> => { | |||
| const map = new Map<string, LotPickRef[]>(); | |||
| materialInputs.forEach((m) => { | |||
| const code = m.pickOrderCode?.trim(); | |||
| if (!code) return; | |||
| const key = materialLotKey(m.materialItemCode, m.materialLotNo); | |||
| const list = map.get(key) ?? []; | |||
| if (!list.some((p) => p.pickOrderCode === code)) { | |||
| list.push({ | |||
| pickOrderCode: code, | |||
| pickOrderId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| }); | |||
| } | |||
| map.set(key, list); | |||
| const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; | |||
| if (nestedInputs.length) { | |||
| mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs)); | |||
| } | |||
| }); | |||
| return map; | |||
| }; | |||
| const buildLotPickOrderMapRecursive = ( | |||
| materialInputs: ItemLotTraceMaterialInput[], | |||
| ): Map<string, LotPickRef[]> => buildLotPickOrderMap(materialInputs); | |||
| const pickOrdersForLot = ( | |||
| map: Map<string, LotPickRef[]>, | |||
| itemCode: string, | |||
| lotNo: string, | |||
| ): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? []; | |||
| const pickOrderCodesLabel = (picks: LotPickRef[]) => | |||
| picks.map((p) => p.pickOrderCode).join(" · "); | |||
| const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => { | |||
| if (!picks.length) return base; | |||
| const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`; | |||
| return base ? `${base} · ${suffix}` : suffix; | |||
| }; | |||
| const pickOrderDetailFields = ( | |||
| picks: LotPickRef[], | |||
| pickLabel: string, | |||
| ): TraceGraphDetailField[] => | |||
| picks.map((p) => | |||
| field(pickLabel, p.pickOrderCode, { | |||
| linkKind: "pick", | |||
| linkCode: p.pickOrderCode, | |||
| linkId: p.pickOrderId, | |||
| consoCode: p.consoCode || p.pickOrderCode, | |||
| }), | |||
| ); | |||
| const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++; | |||
| const pickCtx = (ctx: PreludeBuildContext) => ({ | |||
| nextSeq: () => nextSeq(ctx), | |||
| pickOrderTargetDate: (pickOrderCode: string) => | |||
| ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""), | |||
| }); | |||
| const buildMaterialProductionNodes = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const steps = m.productionSteps ?? []; | |||
| if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return []; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({ | |||
| ...node, | |||
| id: `mat-prod-${m.materialLotNo}-${node.id}`, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "), | |||
| })); | |||
| }; | |||
| const buildMaterialStockInPreludeNodes = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels, | |||
| lotPicks: Map<string, LotPickRef[]>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | |||
| const lotId = m.materialInventoryLotId; | |||
| const origin = m.stockInOrigin; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| (m.purchaseEvents ?? []).forEach((p) => { | |||
| if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return; | |||
| ctx.seenPurchaseLines.add(p.purchaseOrderLineId); | |||
| const purchaseUnit = p.purchaseUnit; | |||
| const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" "); | |||
| const subtitle = | |||
| [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode; | |||
| const meta = [supplierLabel].filter(Boolean).join(" · "); | |||
| nodes.push({ | |||
| id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`, | |||
| kind: "PURCHASE", | |||
| timestamp: p.orderDate, | |||
| sortKey: parseSortKey(p.orderDate, nextSeq(ctx)), | |||
| title: labels.nodePurchase, | |||
| subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder), | |||
| qty: p.orderQty, | |||
| uom: purchaseUnit, | |||
| meta: meta || undefined, | |||
| refType: "PO", | |||
| refCode: p.purchaseOrderCode, | |||
| refId: p.purchaseOrderId, | |||
| docLinkKind: "po", | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryPurchase, | |||
| details: [ | |||
| field(labels.detailItemCode, p.itemCode), | |||
| field(labels.detailItemName, p.itemName), | |||
| field(labels.detailSupplier, supplierLabel), | |||
| field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)), | |||
| field(labels.detailPurchaseUnit, p.purchaseUnit), | |||
| field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, { | |||
| linkKind: "po", | |||
| linkCode: p.purchaseOrderCode, | |||
| linkId: p.purchaseOrderId, | |||
| }), | |||
| field(labels.detailTime, p.orderDate), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| }); | |||
| if (origin && lotId != null) { | |||
| const originType = origin.type.trim().toUpperCase(); | |||
| const linkKind = docLinkFromOriginType(origin.type); | |||
| const subtitle = [ | |||
| m.materialLotNo, | |||
| [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · "); | |||
| if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) { | |||
| ctx.seenReceiptLots.add(lotId); | |||
| nodes.push({ | |||
| id: `mat-receipt-${lotId}-${origin.stockInLineId}`, | |||
| kind: "RECEIPT", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), | |||
| title: labels.nodeReceipt, | |||
| subtitle: appendPickToSubtitle( | |||
| [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| docLinkKind: linkKind, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryReceipt, | |||
| details: [ | |||
| field(labels.detailPurchaseOrderNo, origin.refCode, { | |||
| linkKind, | |||
| linkCode: origin.refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| } else if ( | |||
| originType !== "PO" && | |||
| originType !== "ADJ" && | |||
| !ctx.seenInboundLots.has(lotId) | |||
| ) { | |||
| // Nested JO_CREATED already represents this JO output lot (with 提料 links). | |||
| const skipJoOriginBecauseNested = | |||
| originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim()); | |||
| if (!skipJoOriginBecauseNested) { | |||
| // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). | |||
| ctx.seenInboundLots.add(lotId); | |||
| const isJoOrigin = originType === "JO"; | |||
| nodes.push({ | |||
| id: `mat-in-${lotId}-${origin.stockInLineId}`, | |||
| kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), | |||
| title: isJoOrigin | |||
| ? labels.nodeJoCreated | |||
| : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, | |||
| subtitle: appendPickToSubtitle( | |||
| subtitle || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| docLinkKind: linkKind, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase, | |||
| details: [ | |||
| field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)), | |||
| field( | |||
| isJoOrigin | |||
| ? labels.jobOrder | |||
| : originType === "PO" | |||
| ? labels.detailPurchaseOrderNo | |||
| : labels.detailSourceDoc, | |||
| origin.refCode, | |||
| { | |||
| linkKind, | |||
| linkCode: origin.refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| } | |||
| } | |||
| } | |||
| groupQcResultsBySession(m.qcResults).forEach((group) => { | |||
| const first = group[0]; | |||
| if (!first) return; | |||
| const key = `${first.stockInLineId}-${first.created}`; | |||
| if (ctx.seenQcKeys.has(key)) return; | |||
| ctx.seenQcKeys.add(key); | |||
| const allPassed = group.every((q) => q.qcPassed); | |||
| const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0); | |||
| const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo] | |||
| .filter(Boolean) | |||
| .join(" · "); | |||
| const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? ""; | |||
| nodes.push({ | |||
| id: `mat-qc-${key}`, | |||
| kind: "MATERIAL_QC", | |||
| timestamp: first.created, | |||
| sortKey: parseSortKey(first.created, nextSeq(ctx)), | |||
| title: allPassed ? labels.nodeQcPass : labels.nodeQcFail, | |||
| subtitle: "", | |||
| qty: totalFail, | |||
| uom: matUom, | |||
| meta: meta || undefined, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryQc, | |||
| qcTypeLabel: labels.tr.qcType(qcTypeRaw), | |||
| details: detailsOf( | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailStatus, labels.tr.qcPassed(allPassed)), | |||
| field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)), | |||
| field(labels.detailQcCriteria, "", { | |||
| variant: "qcCriteriaList", | |||
| qcCriteriaItems: group.map((q) => ({ | |||
| name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem, | |||
| description: q.qcItemDescription?.trim() || undefined, | |||
| passed: q.qcPassed, | |||
| failQty: q.failQty > 0 ? q.failQty : undefined, | |||
| })), | |||
| }), | |||
| field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)), | |||
| field(labels.detailFailQty, formatQty(totalFail, matUom)), | |||
| field(labels.detailHandler, first.handledBy), | |||
| field(labels.detailTime, first.created), | |||
| fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ), | |||
| }); | |||
| }); | |||
| let lastMaterialPutawayWh = ""; | |||
| (m.putawayEvents ?? []).forEach((p) => { | |||
| const key = `${p.inventoryLotLineId}-${p.timestamp}`; | |||
| if (ctx.seenPutawayKeys.has(key)) return; | |||
| if (isTransferInboundPutaway(p.refType)) return; | |||
| ctx.seenPutawayKeys.add(key); | |||
| const linkKind = docLinkFromRefType(p.refType); | |||
| const pres = resolvePutawayPresentation( | |||
| labels.tr, | |||
| { | |||
| nodePutaway: labels.nodePutaway, | |||
| nodePutawayTransfer: labels.nodePutawayTransfer, | |||
| putawayTransferDetail: labels.putawayTransferDetail, | |||
| }, | |||
| p.status, | |||
| p.refType, | |||
| ); | |||
| const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · "); | |||
| if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim(); | |||
| nodes.push({ | |||
| id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`, | |||
| kind: "PUTAWAY", | |||
| timestamp: p.timestamp, | |||
| sortKey: parseSortKey(p.timestamp, nextSeq(ctx)), | |||
| title: pres.title, | |||
| subtitle: appendPickToSubtitle( | |||
| [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") || | |||
| m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: p.qty, | |||
| uom: matUom, | |||
| meta: meta || undefined, | |||
| refType: p.refType, | |||
| refCode: p.refCode, | |||
| refId: p.refId, | |||
| docLinkKind: linkKind, | |||
| putawayStatusLabel: pres.chipLabel, | |||
| putawayStatusDetail: pres.statusDetail, | |||
| warehouseCode: p.warehouseCode, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryPutaway, | |||
| details: [ | |||
| field(labels.detailStatus, pres.statusDetail), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field( | |||
| isTransferInboundPutaway(p.refType) | |||
| ? labels.detailInboundSiNo | |||
| : (p.refType ?? "").trim().toUpperCase() === "PO" | |||
| ? labels.detailPurchaseOrderNo | |||
| : (p.refType ?? "").trim().toUpperCase() === "JO" | |||
| ? labels.jobOrder | |||
| : labels.detailSourceDoc, | |||
| p.refCode, | |||
| { | |||
| linkKind, | |||
| linkCode: p.refCode, | |||
| linkId: p.refId, | |||
| }), | |||
| field(labels.detailPutawayBin, p.warehouseCode), | |||
| field(labels.detailQty, formatQty(p.qty, matUom)), | |||
| field(labels.detailHandler, p.handledBy), | |||
| field(labels.detailTime, p.timestamp), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| }); | |||
| // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」. | |||
| if (origin && lotId != null) { | |||
| const originType = origin.type.trim().toUpperCase(); | |||
| if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) { | |||
| ctx.seenInboundLots.add(lotId); | |||
| const adjWh = lastMaterialPutawayWh; | |||
| const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · "); | |||
| // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG. | |||
| const lastPutawayTs = (m.putawayEvents ?? []) | |||
| .map((p) => p.timestamp) | |||
| .filter((t): t is string => Boolean(t?.trim())) | |||
| .sort() | |||
| .at(-1); | |||
| const adjTimestamp = | |||
| lastPutawayTs && | |||
| parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0) | |||
| ? lastPutawayTs | |||
| : origin.receiptDate; | |||
| // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整 | |||
| // (same calendar time previously ordered adj left of putaway → back-arrow). | |||
| const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null; | |||
| const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx)); | |||
| nodes.push({ | |||
| id: `mat-adj-${lotId}-${origin.stockInLineId}`, | |||
| kind: "ADJUSTMENT", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: | |||
| putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase, | |||
| title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`, | |||
| subtitle: appendPickToSubtitle( | |||
| [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo] | |||
| .filter(Boolean) | |||
| .join(" · ") || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| adjustmentDirection: "IN", | |||
| meta: meta || undefined, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| warehouseCode: adjWh || undefined, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryAdjustment, | |||
| details: detailsOf( | |||
| field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)), | |||
| field(labels.detailAdjustmentRef, origin.refCode), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ), | |||
| }); | |||
| } | |||
| } | |||
| return nodes; | |||
| }; | |||
| const buildNestedJoCreatedNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels, | |||
| picks: LotPickRef[], | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode | null => { | |||
| const nested = m.nestedJoPrelude; | |||
| const jo = nested?.jobOrder; | |||
| if (!jo?.jobOrderCode?.trim()) return null; | |||
| const nestedInputs = nested?.materialInputs ?? []; | |||
| const nestedPickTs = nestedInputs | |||
| .map((input) => input.pickedAt) | |||
| .find((ts) => ts?.trim()); | |||
| const nestedProdTs = (m.productionSteps ?? []) | |||
| .map((step) => step.startTime) | |||
| .find((ts) => ts?.trim()); | |||
| const origin = m.stockInOrigin; | |||
| const ts = | |||
| jo.createdAt?.trim() || | |||
| jo.planStart?.trim() || | |||
| nestedPickTs?.trim() || | |||
| nestedProdTs?.trim() || | |||
| origin?.receiptDate?.trim() || | |||
| null; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const qty = origin?.acceptedQty ?? jo.reqQty; | |||
| return { | |||
| id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, | |||
| kind: "JO_CREATED", | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, nextSeq(ctx)), | |||
| title: labels.nodeJoCreated, | |||
| subtitle: appendPickToSubtitle( | |||
| [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty, | |||
| uom: matUom, | |||
| refType: "JO", | |||
| refCode: jo.jobOrderCode, | |||
| refId: jo.jobOrderId, | |||
| docLinkKind: "jo", | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeJoCreated), | |||
| field(labels.jobOrder, jo.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: jo.jobOrderCode, | |||
| linkId: jo.jobOrderId, | |||
| }), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(qty, matUom)), | |||
| field(labels.detailTime, ts), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }; | |||
| }; | |||
| const buildMaterialInputChainNodes = ( | |||
| materialInputs: ItemLotTraceMaterialInput[], | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| lotPicks: Map<string, LotPickRef[]>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| materialInputs.forEach((m) => { | |||
| const joProduced = isJoProducedMaterial(m); | |||
| const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; | |||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | |||
| if (joProduced && m.nestedJoPrelude) { | |||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); | |||
| if (nestedJoCreated) nodes.push(nestedJoCreated); | |||
| } | |||
| if (joProduced && nestedInputs.length > 0) { | |||
| const nestedLotPicks = buildLotPickOrderMap(nestedInputs); | |||
| nodes.push( | |||
| ...buildMaterialInputChainNodes( | |||
| nestedInputs, | |||
| labels, | |||
| nestedLotPicks, | |||
| ctx, | |||
| ), | |||
| ); | |||
| nestedInputs.forEach((nm, ni) => { | |||
| nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo)); | |||
| }); | |||
| } | |||
| if (joProduced && (m.productionSteps?.length ?? 0) > 0) { | |||
| nodes.push(...buildMaterialProductionNodes(m, labels, ctx)); | |||
| } | |||
| nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx)); | |||
| }); | |||
| return nodes; | |||
| }; | |||
| export const buildJoPreludeGraphNodes = ( | |||
| joPrelude: ItemLotTraceJoPrelude, | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| ): TraceGraphNode[] => { | |||
| const ctx: PreludeBuildContext = { | |||
| seenPurchaseLines: new Set(), | |||
| seenInboundLots: new Set(), | |||
| seenReceiptLots: new Set(), | |||
| seenQcKeys: new Set(), | |||
| seenPutawayKeys: new Set(), | |||
| pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude), | |||
| seq: 0, | |||
| }; | |||
| const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); | |||
| const nodes: TraceGraphNode[] = [ | |||
| ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), | |||
| ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), | |||
| ]; | |||
| return nodes.sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| @@ -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)); | |||
| @@ -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; | |||
| }; | |||
| @@ -0,0 +1,323 @@ | |||
| import { MarkerType, type Edge, type Node } from "@xyflow/react"; | |||
| import { | |||
| COLUMN_WIDTH_MIN, | |||
| HEADER_HEIGHT, | |||
| LANE_GAP, | |||
| LANE_MIN_HEIGHT, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| NODE_WIDTH_BRANCH, | |||
| PHASE_LABEL_WIDTH, | |||
| } from "./traceFlowConstants"; | |||
| import { | |||
| CELL_PAD_X, | |||
| layoutCellMembers, | |||
| computeLaneTops, | |||
| } from "./traceFlowLayout"; | |||
| import { phaseAccent } from "./traceFlowNodeUtils"; | |||
| import { TraceGraphLayout, TraceGraphLayoutNode, buildTraceFlowEdgePairs } from "./traceGraphLayout"; | |||
| import type { TraceFlowEdgePair } from "./traceGraphSemantics"; | |||
| import { | |||
| assignTraceFlowEdgeRouting, | |||
| enrichTraceFlowCorridorRouting, | |||
| type TraceFlowNodeRect, | |||
| } from "./traceFlowEdgeLayout"; | |||
| import { | |||
| isDoGroupChild, | |||
| isFlowGroupContainer, | |||
| layoutDoGroupChildPlacements, | |||
| } from "./traceDoGroupLayout"; | |||
| export type TraceFlowNodeData = { | |||
| layoutNode: TraceGraphLayoutNode; | |||
| compact: boolean; | |||
| onTrace?: (params: { stockInLineId?: number; lotNo?: string; itemCode?: string }) => void; | |||
| phaseLabel?: string; | |||
| dateLabel?: string; | |||
| phaseColor?: string; | |||
| incomingHandleCount?: number; | |||
| outgoingHandleCount?: number; | |||
| searchActive?: boolean; | |||
| searchMatch?: boolean; | |||
| searchFocused?: boolean; | |||
| nodeSelected?: boolean; | |||
| /** DO / pick group header collapse. */ | |||
| groupCollapsed?: boolean; | |||
| onToggleGroupCollapse?: () => void; | |||
| }; | |||
| const cellKey = (laneIndex: number, column: number, stagger: number) => | |||
| `${laneIndex}:${column}:${stagger}`; | |||
| export const buildReactFlowGraph = ( | |||
| layout: TraceGraphLayout, | |||
| phaseLabels: Record<string, string>, | |||
| edgePairs?: TraceFlowEdgePair[], | |||
| ): { nodes: Node<TraceFlowNodeData>[]; edges: Edge[]; graphHeight: number } => { | |||
| const nodes: Node<TraceFlowNodeData>[] = []; | |||
| const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n)); | |||
| const cells = new Map<string, TraceGraphLayoutNode[]>(); | |||
| topLevelNodes.forEach((n) => { | |||
| const key = cellKey(n.laneIndex, n.column, n.dayPhaseStaggerIndex); | |||
| const list = cells.get(key) ?? []; | |||
| list.push(n); | |||
| cells.set(key, list); | |||
| }); | |||
| const cellLayouts = new Map<string, ReturnType<typeof layoutCellMembers>>(); | |||
| const { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts } = layout.dayColumns; | |||
| cells.forEach((members, key) => { | |||
| const col = Number(key.split(":")[1]); | |||
| const stagger = members[0]?.dayPhaseStaggerIndex ?? 0; | |||
| const slotW = phaseSlotWidths[col]?.[stagger] ?? COLUMN_WIDTH_MIN; | |||
| cellLayouts.set(key, layoutCellMembers(members, slotW - CELL_PAD_X * 2)); | |||
| }); | |||
| const doChildrenByGroup = new Map<string, TraceGraphLayoutNode[]>(); | |||
| layout.nodes.forEach((n) => { | |||
| if (!n.doGroupId) return; | |||
| const list = doChildrenByGroup.get(n.doGroupId) ?? []; | |||
| list.push(n); | |||
| doChildrenByGroup.set(n.doGroupId, list); | |||
| }); | |||
| const childPlacementsByGroup = new Map< | |||
| string, | |||
| ReturnType<typeof layoutDoGroupChildPlacements> | |||
| >(); | |||
| doChildrenByGroup.forEach((children, groupId) => { | |||
| childPlacementsByGroup.set(groupId, layoutDoGroupChildPlacements(children)); | |||
| }); | |||
| const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells); | |||
| layout.columnDates.forEach((date, col) => { | |||
| const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN; | |||
| const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN; | |||
| nodes.push({ | |||
| id: `hdr-${col}`, | |||
| type: "dateHeader", | |||
| position: { | |||
| x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40, | |||
| y: 6, | |||
| }, | |||
| data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date }, | |||
| draggable: false, | |||
| selectable: false, | |||
| connectable: false, | |||
| focusable: false, | |||
| }); | |||
| }); | |||
| layout.phaseOrder.forEach((phase, laneIdx) => { | |||
| const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT; | |||
| nodes.push({ | |||
| id: `phase-${phase}`, | |||
| type: "phaseLabel", | |||
| position: { | |||
| x: 8, | |||
| y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2), | |||
| }, | |||
| data: { | |||
| layoutNode: {} as TraceGraphLayoutNode, | |||
| compact: false, | |||
| phaseLabel: phaseLabels[phase] ?? phase, | |||
| phaseColor: phaseAccent(phase), | |||
| }, | |||
| draggable: false, | |||
| selectable: false, | |||
| connectable: false, | |||
| focusable: false, | |||
| }); | |||
| }); | |||
| topLevelNodes.forEach((layoutNode) => { | |||
| const key = cellKey(layoutNode.laneIndex, layoutNode.column, layoutNode.dayPhaseStaggerIndex); | |||
| const placed = cellLayouts.get(key)?.get(layoutNode.id); | |||
| if (!placed) return; | |||
| const dayStart = columnStarts[layoutNode.column] ?? layoutNode.column * COLUMN_WIDTH_MIN; | |||
| const phaseSlotX = | |||
| phaseSlotStarts[layoutNode.column]?.[layoutNode.dayPhaseStaggerIndex] ?? 0; | |||
| const isGroup = isFlowGroupContainer(layoutNode); | |||
| const groupW = layoutNode.groupBoxWidth ?? NODE_WIDTH; | |||
| const groupH = layoutNode.groupBoxHeight ?? NODE_HEIGHT; | |||
| nodes.push({ | |||
| id: layoutNode.id, | |||
| type: layoutNode.kind === "PICK_GROUP" ? "pickGroup" : isGroup ? "doGroup" : "traceEvent", | |||
| position: { | |||
| x: PHASE_LABEL_WIDTH + dayStart + phaseSlotX + CELL_PAD_X + placed.x, | |||
| y: laneTops[layoutNode.laneIndex] + placed.y, | |||
| }, | |||
| data: { layoutNode, compact: placed.compact }, | |||
| draggable: false, | |||
| connectable: false, | |||
| selectable: !isGroup, | |||
| zIndex: isGroup ? 0 : 1, | |||
| style: isGroup ? { width: groupW, height: groupH } : undefined, | |||
| width: isGroup ? groupW : undefined, | |||
| height: isGroup ? groupH : undefined, | |||
| measured: { | |||
| width: isGroup ? groupW : placed.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, | |||
| height: isGroup ? groupH : NODE_HEIGHT, | |||
| }, | |||
| }); | |||
| }); | |||
| const parentPositions = new Map<string, { x: number; y: number }>(); | |||
| nodes.forEach((node) => { | |||
| if (node.type === "doGroup" || node.type === "pickGroup") { | |||
| parentPositions.set(node.id, node.position); | |||
| } | |||
| }); | |||
| doChildrenByGroup.forEach((children, groupId) => { | |||
| const placements = childPlacementsByGroup.get(groupId); | |||
| const parentPos = parentPositions.get(groupId); | |||
| if (!placements || !parentPos) return; | |||
| children.forEach((layoutNode) => { | |||
| const placed = placements.get(layoutNode.id); | |||
| if (!placed) return; | |||
| nodes.push({ | |||
| id: layoutNode.id, | |||
| type: "traceEvent", | |||
| position: { | |||
| x: parentPos.x + placed.x, | |||
| y: parentPos.y + placed.y, | |||
| }, | |||
| data: { layoutNode, compact: placed.compact }, | |||
| draggable: false, | |||
| connectable: false, | |||
| zIndex: 2, | |||
| width: NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| measured: { | |||
| width: NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| }, | |||
| }); | |||
| }); | |||
| }); | |||
| const flowPairs = edgePairs ?? buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder); | |||
| const { routed, incomingCount, outgoingCount } = assignTraceFlowEdgeRouting(flowPairs); | |||
| const nodeRects = new Map<string, TraceFlowNodeRect>(); | |||
| nodes.forEach((node) => { | |||
| if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return; | |||
| const layoutNode = node.data.layoutNode; | |||
| if (layoutNode.doGroupId) return; | |||
| nodeRects.set(node.id, { | |||
| x: node.position.x, | |||
| y: node.position.y, | |||
| width: node.measured?.width ?? NODE_WIDTH, | |||
| height: node.measured?.height ?? NODE_HEIGHT, | |||
| laneIndex: layoutNode.laneIndex, | |||
| }); | |||
| }); | |||
| const corridorRouted = enrichTraceFlowCorridorRouting(routed, nodeRects, { | |||
| laneTops, | |||
| laneHeights, | |||
| laneGap: LANE_GAP, | |||
| }); | |||
| const nodesWithHandles = nodes.map((node) => { | |||
| if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return node; | |||
| if (node.data.layoutNode.doGroupId) { | |||
| return { | |||
| ...node, | |||
| data: { | |||
| ...node.data, | |||
| incomingHandleCount: 0, | |||
| outgoingHandleCount: 0, | |||
| }, | |||
| }; | |||
| } | |||
| return { | |||
| ...node, | |||
| data: { | |||
| ...node.data, | |||
| incomingHandleCount: incomingCount.get(node.id) ?? 0, | |||
| outgoingHandleCount: outgoingCount.get(node.id) ?? 0, | |||
| }, | |||
| }; | |||
| }); | |||
| const nodeById = new Map(layout.nodes.map((n) => [n.id, n])); | |||
| const edges: Edge[] = corridorRouted.map( | |||
| ({ | |||
| fromId, | |||
| toId, | |||
| sourceHandle, | |||
| targetHandle, | |||
| offset, | |||
| pathMode, | |||
| corridorY, | |||
| corridorEntryOffset, | |||
| corridorExitOffset, | |||
| corridorBranchOffset, | |||
| }) => { | |||
| const targetNode = nodeById.get(toId); | |||
| const ref = targetNode?.refCode?.trim(); | |||
| const flowLabel = | |||
| targetNode?.kind === "DO_GROUP" && targetNode.groupMemberCount | |||
| ? String(targetNode.groupMemberCount) | |||
| : targetNode?.kind === "PICK_GROUP" && ref | |||
| ? ref | |||
| : targetNode?.kind === "MATERIAL_PICK" && ref | |||
| ? ref | |||
| : (targetNode?.kind === "OUT" || targetNode?.kind === "DO_OUT") && ref | |||
| ? ref | |||
| : undefined; | |||
| return { | |||
| id: `e-${fromId}-${toId}`, | |||
| source: fromId, | |||
| target: toId, | |||
| sourceHandle, | |||
| targetHandle, | |||
| type: "traceFlow", | |||
| data: { | |||
| offset, | |||
| pathMode, | |||
| corridorY, | |||
| corridorEntryOffset, | |||
| corridorExitOffset, | |||
| corridorBranchOffset, | |||
| }, | |||
| label: flowLabel, | |||
| labelStyle: { fontSize: 10, fontWeight: 600, fill: "#5d4037" }, | |||
| labelBgStyle: { fill: "#fff8e1", fillOpacity: 0.95 }, | |||
| labelBgPadding: [4, 6] as [number, number], | |||
| labelBgBorderRadius: 4, | |||
| style: { stroke: "#757575", strokeWidth: 1.5, opacity: 0.75 }, | |||
| markerEnd: { type: MarkerType.ArrowClosed, color: "#757575", width: 16, height: 16 }, | |||
| }; | |||
| }); | |||
| return { nodes: nodesWithHandles, edges, graphHeight: totalHeight }; | |||
| }; | |||
| export const reactFlowGraphExtent = ( | |||
| layout: TraceGraphLayout, | |||
| graphHeight?: number, | |||
| ): [[number, number], [number, number]] => { | |||
| const width = PHASE_LABEL_WIDTH + layout.dayColumns.timelineWidth + 32; | |||
| const height = graphHeight ?? HEADER_HEIGHT + layout.laneCount * LANE_MIN_HEIGHT + 32; | |||
| // Extra bottom room so expanded stock-take lifecycle panels stay pannable. | |||
| const padX = 48; | |||
| const padTop = 48; | |||
| const padBottom = 360; | |||
| return [ | |||
| [-padX, -padTop], | |||
| [width + padX, height + padBottom], | |||
| ]; | |||
| }; | |||
| export type ReactFlowGraphResult = ReturnType<typeof buildReactFlowGraph>; | |||
| @@ -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, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,905 @@ | |||
| import type { TFunction } from "i18next"; | |||
| import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { | |||
| exportMultiSheetToXlsx, | |||
| type MultiSheetSpec, | |||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import type { CompiledTraceGraph } from "./compileTraceGraph"; | |||
| import { | |||
| mergeScopedAdjustments, | |||
| mergeScopedDoDeliveries, | |||
| mergeScopedFailEvents, | |||
| mergeScopedOpenMovements, | |||
| mergeScopedOrigins, | |||
| mergeScopedOutboundUsage, | |||
| mergeScopedPurchaseEvents, | |||
| mergeScopedPutawayEvents, | |||
| mergeScopedQcResults, | |||
| mergeScopedReplenishmentEvents, | |||
| mergeScopedReturnEvents, | |||
| mergeScopedStockTakeEvents, | |||
| mergeScopedTransfers, | |||
| } from "./mergeLocationScopedData"; | |||
| import { | |||
| resolveStockTakeAcceptedQty, | |||
| resolveStockTakeBookQty, | |||
| } from "./traceStockTakeUtils"; | |||
| import { | |||
| createTraceLabelTranslator, | |||
| type TraceLabelTranslator, | |||
| } from "./traceLabelUtils"; | |||
| import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils"; | |||
| import { buildTracePresentationRows } from "./tracePresentationAdapter"; | |||
| import { isDoGroupChild } from "./traceDoGroupLayout"; | |||
| const NO_DATA = "(無資料 / No records)"; | |||
| const SHEET = { | |||
| summary: "摘要 Summary", | |||
| timeline: "生命週期時間軸 Timeline", | |||
| origins: "來源與採購 Origins", | |||
| qc: "品質檢驗 QC", | |||
| warehouse: "倉儲作業 Warehouse", | |||
| outbound: "出庫 Outbound", | |||
| stockTake: "盤點 Stock Take", | |||
| production: "生產與BOM Production", | |||
| } as const; | |||
| export type ItemLotTraceExportLabels = { | |||
| bomDirectionFg: string; | |||
| bomDirectionMaterial: string; | |||
| bomDirectionUnknown: string; | |||
| }; | |||
| const orDash = (v: string | number | null | undefined): string | number => { | |||
| if (v == null) return "—"; | |||
| if (typeof v === "string" && !v.trim()) return "—"; | |||
| return v; | |||
| }; | |||
| const bomDirectionLabel = ( | |||
| direction: string, | |||
| labels: ItemLotTraceExportLabels, | |||
| ): string => { | |||
| const d = direction.trim().toUpperCase(); | |||
| if (d === "FINISHED_GOOD") return labels.bomDirectionFg; | |||
| if (d === "MATERIAL") return labels.bomDirectionMaterial; | |||
| return labels.bomDirectionUnknown; | |||
| }; | |||
| const ensureRows = ( | |||
| rows: Record<string, unknown>[], | |||
| emptyTemplate: Record<string, unknown>, | |||
| ): Record<string, unknown>[] => { | |||
| if (rows.length > 0) return rows; | |||
| const firstKey = Object.keys(emptyTemplate)[0]; | |||
| return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }]; | |||
| }; | |||
| const todayYmd = (): string => { | |||
| const d = new Date(); | |||
| const y = d.getFullYear(); | |||
| const m = String(d.getMonth() + 1).padStart(2, "0"); | |||
| const day = String(d.getDate()).padStart(2, "0"); | |||
| return `${y}${m}${day}`; | |||
| }; | |||
| const safeFilenamePart = (s: string): string => | |||
| s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown"; | |||
| export const buildItemLotTraceFilename = ( | |||
| data: ItemLotTraceResponse, | |||
| ): string => { | |||
| const item = safeFilenamePart(data.lot.itemCode || "item"); | |||
| const lot = safeFilenamePart(data.lot.lotNo || "lot"); | |||
| return `批號追溯_${item}_${lot}_${todayYmd()}`; | |||
| }; | |||
| const summaryEmpty = (): Record<string, unknown> => ({ | |||
| "Field / 欄位": "", | |||
| "Value / 值": "", | |||
| }); | |||
| const buildSummarySheet = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: ItemLotTraceExportLabels, | |||
| exportAt: string, | |||
| ): Record<string, unknown>[] => { | |||
| const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data; | |||
| const totalAvailable = warehouseLines.reduce( | |||
| (s, w) => s + (w.availableQty ?? 0), | |||
| 0, | |||
| ); | |||
| const rows: Record<string, unknown>[] = [ | |||
| { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) }, | |||
| { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) }, | |||
| { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) }, | |||
| { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) }, | |||
| { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) }, | |||
| { | |||
| "Field / 欄位": "Production Date / 生產日期", | |||
| "Value / 值": orDash(lot.productionDate), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Stock In Date / 入庫日期", | |||
| "Value / 值": orDash(lot.stockInDate), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Total Available / 總可用量", | |||
| "Value / 值": totalAvailable, | |||
| }, | |||
| { | |||
| "Field / 欄位": "BOM Direction / BOM 方向", | |||
| "Value / 值": bomDirectionLabel(bomTrace.direction, labels), | |||
| }, | |||
| ]; | |||
| if (joPrelude) { | |||
| const jo = joPrelude.jobOrder; | |||
| rows.push( | |||
| { | |||
| "Field / 欄位": "Job Order / 工單", | |||
| "Value / 值": orDash(jo.jobOrderCode), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Plan Start / 計劃開工", | |||
| "Value / 值": orDash(jo.planStart), | |||
| }, | |||
| { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty }, | |||
| { | |||
| "Field / 欄位": "JO Status / 工單狀態", | |||
| "Value / 值": orDash(jo.status), | |||
| }, | |||
| ); | |||
| } | |||
| rows.push( | |||
| { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt }, | |||
| { "Field / 欄位": "", "Value / 值": "" }, | |||
| { | |||
| "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —", | |||
| "Value / 值": "", | |||
| }, | |||
| ); | |||
| warehouseLines.forEach((w) => { | |||
| rows.push({ | |||
| "Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`, | |||
| "Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`, | |||
| }); | |||
| }); | |||
| if (alternateLocations.length > 0) { | |||
| rows.push( | |||
| { "Field / 欄位": "", "Value / 值": "" }, | |||
| { | |||
| "Field / 欄位": "— Alternate Locations / 其他倉位 —", | |||
| "Value / 值": "", | |||
| }, | |||
| ); | |||
| alternateLocations.forEach((loc) => { | |||
| rows.push({ | |||
| "Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`, | |||
| "Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`, | |||
| }); | |||
| }); | |||
| } | |||
| return rows.length > 0 ? rows : [summaryEmpty()]; | |||
| }; | |||
| const timelineEmpty = (): Record<string, unknown> => ({ | |||
| "Date/Time / 時間": "", | |||
| "Phase / 階段": "", | |||
| "Event Type / 事件類型": "", | |||
| "Doc No. / 單號": "", | |||
| "Ref Type / 來源類型": "", | |||
| "Ref Type Code": "", | |||
| "Qty / 數量": "", | |||
| "UOM / 單位": "", | |||
| "Warehouse / 倉位": "", | |||
| "Handler / 經手人": "", | |||
| "Lot / 批號": "", | |||
| "Remarks / 備註": "", | |||
| }); | |||
| const buildTimelineSheet = ( | |||
| compiledGraph: CompiledTraceGraph, | |||
| t: TFunction, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const nodes = compiledGraph.layout.nodes | |||
| .filter( | |||
| (n) => | |||
| n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n), | |||
| ) | |||
| .slice() | |||
| .sort((a, b) => { | |||
| const ta = a.timestamp ?? ""; | |||
| const tb = b.timestamp ?? ""; | |||
| if (ta !== tb) return ta.localeCompare(tb); | |||
| return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex; | |||
| }); | |||
| const rows = nodes.map((n) => { | |||
| const handlerDetail = n.details.find( | |||
| (d) => | |||
| d.label.toLowerCase().includes("handler") || | |||
| d.label.includes("經手") || | |||
| d.label.includes("核准") || | |||
| d.label.includes("盤點"), | |||
| ); | |||
| return { | |||
| "Date/Time / 時間": orDash(n.timestamp), | |||
| "Phase / 階段": t(phaseLabelKey(n.phase)), | |||
| "Event Type / 事件類型": t(kindLabelKey(n.kind)), | |||
| "Doc No. / 單號": orDash(n.refCode), | |||
| "Ref Type / 來源類型": tr.refType(n.refType), | |||
| "Ref Type Code": orDash(n.refType), | |||
| "Qty / 數量": n.qty ?? "", | |||
| "UOM / 單位": orDash(n.uom), | |||
| "Warehouse / 倉位": orDash(n.warehouseCode), | |||
| "Handler / 經手人": orDash( | |||
| handlerDetail?.value ?? n.meta?.split(" · ")[0], | |||
| ), | |||
| "Lot / 批號": orDash(n.traceLotNo), | |||
| "Remarks / 備註": orDash(n.subtitle), | |||
| }; | |||
| }); | |||
| return ensureRows(rows, timelineEmpty()); | |||
| }; | |||
| const originsEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Type / 類型": "", | |||
| "Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "Supplier Code / 供應商編號": "", | |||
| "Supplier / 供應商": "", | |||
| "DN No. / 送貨單號": "", | |||
| "Qty / 數量": "", | |||
| "Status / 狀態": "", | |||
| "Date / 日期": "", | |||
| }); | |||
| const buildOriginsSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const originRows = mergeScopedOrigins(data).map((o) => ({ | |||
| "Section / 區塊": "Origin / 來源", | |||
| "Warehouse / 倉位": o.scopeWarehouseCode, | |||
| "Inventory Lot Id": o.inventoryLotId, | |||
| "Type / 類型": tr.refType(o.type), | |||
| "Type Code": orDash(o.type), | |||
| "Doc No. / 單號": orDash(o.refCode), | |||
| "Supplier Code / 供應商編號": orDash(o.supplierCode), | |||
| "Supplier / 供應商": orDash(o.supplierName), | |||
| "DN No. / 送貨單號": orDash(o.dnNo), | |||
| "Qty / 數量": o.acceptedQty, | |||
| "Status / 狀態": tr.stockInStatus(o.status), | |||
| "Date / 日期": orDash(o.receiptDate), | |||
| })); | |||
| const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({ | |||
| "Section / 區塊": "Purchase / 採購", | |||
| "Warehouse / 倉位": p.scopeWarehouseCode, | |||
| "Inventory Lot Id": p.inventoryLotId, | |||
| "Type / 類型": tr.refType("PO"), | |||
| "Type Code": "PO", | |||
| "Doc No. / 單號": orDash(p.purchaseOrderCode), | |||
| "Supplier Code / 供應商編號": orDash(p.supplierCode), | |||
| "Supplier / 供應商": orDash(p.supplierName), | |||
| "DN No. / 送貨單號": "", | |||
| "Qty / 數量": p.orderQty, | |||
| "Status / 狀態": orDash(p.status), | |||
| "Date / 日期": orDash(p.orderDate), | |||
| })); | |||
| return ensureRows([...originRows, ...purchaseRows], originsEmpty()); | |||
| }; | |||
| const qcEmpty = (): Record<string, unknown> => ({ | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "QC Result / 結果": "", | |||
| "QC Type / 品檢類型": "", | |||
| "QC Type Code": "", | |||
| "QC Item Code / 品檢項編號": "", | |||
| "QC Item / 品檢項": "", | |||
| "Accepted Qty / 抽樣數量": "", | |||
| "Fail Qty / 不良數量": "", | |||
| "Remarks / 備註": "", | |||
| "Handler / 經手人": "", | |||
| "Date/Time / 時間": "", | |||
| "Stock In Line Id": "", | |||
| }); | |||
| const buildQcSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const rows = mergeScopedQcResults(data).map((q) => ({ | |||
| "Warehouse / 倉位": q.scopeWarehouseCode, | |||
| "Inventory Lot Id": q.inventoryLotId, | |||
| "QC Result / 結果": tr.qcPassed(q.qcPassed), | |||
| "QC Type / 品檢類型": tr.qcType(q.qcType), | |||
| "QC Type Code": orDash(q.qcType), | |||
| "QC Item Code / 品檢項編號": orDash(q.qcItemCode), | |||
| "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription), | |||
| "Accepted Qty / 抽樣數量": q.acceptedQty, | |||
| "Fail Qty / 不良數量": q.failQty, | |||
| "Remarks / 備註": orDash(q.remarks), | |||
| "Handler / 經手人": orDash(q.handledBy), | |||
| "Date/Time / 時間": orDash(q.created), | |||
| "Stock In Line Id": q.stockInLineId, | |||
| })); | |||
| return ensureRows(rows, qcEmpty()); | |||
| }; | |||
| const warehouseEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Direction / 方向": "", | |||
| "Type / 類型": "", | |||
| "Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": "", | |||
| "Qty / 數量": "", | |||
| "Handler / 經手人": "", | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildWarehouseSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const putaway = mergeScopedPutawayEvents(data).map((p) => ({ | |||
| "Section / 區塊": "Putaway / 上架", | |||
| "Warehouse / 倉位": p.scopeWarehouseCode, | |||
| "Inventory Lot Id": p.inventoryLotId, | |||
| "Direction / 方向": "", | |||
| "Type / 類型": tr.refType(p.refType), | |||
| "Type Code": orDash(p.refType), | |||
| "Doc No. / 單號": orDash(p.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(p.warehouseCode), | |||
| "Qty / 數量": p.qty, | |||
| "Handler / 經手人": orDash(p.handledBy), | |||
| "Status / 狀態": orDash(p.status), | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(p.timestamp), | |||
| })); | |||
| const transfers = mergeScopedTransfers(data).map((trRow) => ({ | |||
| "Section / 區塊": "Transfer / 轉倉", | |||
| "Warehouse / 倉位": trRow.scopeWarehouseCode, | |||
| "Inventory Lot Id": trRow.inventoryLotId, | |||
| "Direction / 方向": "", | |||
| "Type / 類型": tr.refType("TRANSFER"), | |||
| "Type Code": "TRANSFER", | |||
| "Doc No. / 單號": orDash(trRow.transferCode), | |||
| "From / 來源倉": orDash(trRow.fromWarehouse), | |||
| "To / 目標倉": orDash(trRow.toWarehouse), | |||
| "Qty / 數量": trRow.qty, | |||
| "Handler / 經手人": "", | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(trRow.timestamp), | |||
| })); | |||
| const adjustments = mergeScopedAdjustments(data).map((a) => ({ | |||
| "Section / 區塊": "Adjustment / 庫存調整", | |||
| "Warehouse / 倉位": a.scopeWarehouseCode, | |||
| "Inventory Lot Id": a.inventoryLotId, | |||
| "Direction / 方向": tr.direction(a.direction), | |||
| "Type / 類型": tr.adjustmentType(a.adjustmentType), | |||
| "Type Code": orDash(a.adjustmentType), | |||
| "Doc No. / 單號": orDash(a.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(a.warehouseCode), | |||
| "Qty / 數量": a.qty, | |||
| "Handler / 經手人": orDash(a.handledBy), | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": orDash(a.reason), | |||
| "Date/Time / 時間": orDash(a.timestamp), | |||
| })); | |||
| const openMoves = mergeScopedOpenMovements(data).map((o) => ({ | |||
| "Section / 區塊": "Opening Stock / 開倉入庫", | |||
| "Warehouse / 倉位": o.scopeWarehouseCode, | |||
| "Inventory Lot Id": o.inventoryLotId, | |||
| "Direction / 方向": tr.direction("IN"), | |||
| "Type / 類型": tr.refType("OPEN"), | |||
| "Type Code": "OPEN", | |||
| "Doc No. / 單號": orDash(o.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(o.warehouseCode), | |||
| "Qty / 數量": o.qty, | |||
| "Handler / 經手人": orDash(o.handledBy), | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": orDash(o.remarks), | |||
| "Date/Time / 時間": orDash(o.timestamp), | |||
| })); | |||
| return ensureRows( | |||
| [...putaway, ...transfers, ...adjustments, ...openMoves], | |||
| warehouseEmpty(), | |||
| ); | |||
| }; | |||
| const outboundEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Usage Type / 類型": "", | |||
| "Usage Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": "", | |||
| "Handler / 經手人": "", | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildOutboundSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const usage = mergeScopedOutboundUsage(data).map((u) => ({ | |||
| "Section / 區塊": "Outbound Usage / 出庫使用", | |||
| "Warehouse / 倉位": u.scopeWarehouseCode, | |||
| "Inventory Lot Id": u.inventoryLotId, | |||
| "Usage Type / 類型": tr.usageType(u.usageType), | |||
| "Usage Type Code": orDash(u.usageType), | |||
| "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode), | |||
| "Pick Order / 提料單": orDash(u.pickOrderCode), | |||
| "Conso / 併單": orDash(u.consoCode), | |||
| "Job Order / 工單": orDash(u.jobOrderCode), | |||
| "Delivery Order / 送貨單": orDash(u.deliveryOrderCode), | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": u.qty, | |||
| "Handler / 經手人": orDash(u.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(u.timestamp), | |||
| })); | |||
| const doRows = mergeScopedDoDeliveries(data).map((d) => ({ | |||
| "Section / 區塊": "DO Delivery / 成品出倉", | |||
| "Warehouse / 倉位": d.scopeWarehouseCode, | |||
| "Inventory Lot Id": d.inventoryLotId, | |||
| "Usage Type / 類型": tr.usageType("FG_DELIVERY"), | |||
| "Usage Type Code": "FG_DELIVERY", | |||
| "Doc No. / 單號": orDash(d.deliveryOrderCode), | |||
| "Pick Order / 提料單": orDash(d.pickOrderCode), | |||
| "Conso / 併單": orDash(d.consoCode), | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": orDash(d.deliveryOrderCode), | |||
| "Delivery Note / DN": orDash(d.deliveryNoteCode), | |||
| "Ticket / 票號": orDash(d.ticketNo), | |||
| "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—", | |||
| "Qty / 數量": d.qty, | |||
| "Handler / 經手人": orDash(d.handler), | |||
| "Extra / 加單": d.isExtra ? "Y" : "", | |||
| "Replenish / 補貨": d.isReplenish ? "Y" : "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(d.timestamp), | |||
| })); | |||
| const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({ | |||
| "Section / 區塊": "Replenishment / 補貨", | |||
| "Warehouse / 倉位": r.scopeWarehouseCode, | |||
| "Inventory Lot Id": r.inventoryLotId, | |||
| "Usage Type / 類型": "Replenishment", | |||
| "Usage Type Code": "REPLENISH", | |||
| "Doc No. / 單號": orDash(r.replenishmentCode), | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": orDash(r.sourceDoCode), | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—", | |||
| "Qty / 數量": r.replenishQty, | |||
| "Handler / 經手人": orDash(r.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "Y", | |||
| "Remarks / 備註": orDash(r.reason), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| })); | |||
| const returns = mergeScopedReturnEvents(data).map((r) => ({ | |||
| "Section / 區塊": "Return / 退貨", | |||
| "Warehouse / 倉位": r.scopeWarehouseCode, | |||
| "Inventory Lot Id": r.inventoryLotId, | |||
| "Usage Type / 類型": tr.movementType(r.movementType), | |||
| "Usage Type Code": orDash(r.movementType), | |||
| "Doc No. / 單號": orDash(r.refCode), | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": r.qty, | |||
| "Handler / 經手人": orDash(r.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": orDash(r.remarks), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| })); | |||
| const fails = mergeScopedFailEvents(data).map((f) => ({ | |||
| "Section / 區塊": "Pick Failure / 揀貨異常", | |||
| "Warehouse / 倉位": f.scopeWarehouseCode, | |||
| "Inventory Lot Id": f.inventoryLotId, | |||
| "Usage Type / 類型": tr.failType(f.failType), | |||
| "Usage Type Code": orDash(f.failType), | |||
| "Doc No. / 單號": orDash(f.pickOrderCode), | |||
| "Pick Order / 提料單": orDash(f.pickOrderCode), | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": f.qty, | |||
| "Handler / 經手人": orDash(f.handlerName), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": orDash(f.category), | |||
| "Date/Time / 時間": orDash(f.recordDate), | |||
| })); | |||
| return ensureRows( | |||
| [...usage, ...doRows, ...replenish, ...returns, ...fails], | |||
| outboundEmpty(), | |||
| ); | |||
| }; | |||
| const stockTakeEmpty = (): Record<string, unknown> => ({ | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Stock Take Code / 盤點單號": "", | |||
| "Round / 輪次": "", | |||
| "Section / 區域": "", | |||
| "Lot / 批號": "", | |||
| "Book Qty / 帳面數量": "", | |||
| "Accepted Qty / 核准數量": "", | |||
| "Variance Qty / 差異數量": "", | |||
| "Approver / 核准人": "", | |||
| "Stock Taker / 初盤人": "", | |||
| "First Count Qty / 初盤數量": "", | |||
| "Second Count Qty / 複盤數量": "", | |||
| "Approver Count Qty / 覆盤數量": "", | |||
| "Record Status / 紀錄狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildStockTakeSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| ): Record<string, unknown>[] => { | |||
| const rows = mergeScopedStockTakeEvents(data).map((e) => { | |||
| const d = e.recordDetail; | |||
| return { | |||
| "Warehouse / 倉位": e.scopeWarehouseCode, | |||
| "Inventory Lot Id": e.inventoryLotId, | |||
| "Stock Take Code / 盤點單號": orDash(e.stockTakeCode), | |||
| "Round / 輪次": orDash( | |||
| e.stockTakeRoundName ?? | |||
| (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""), | |||
| ), | |||
| "Section / 區域": orDash(e.stockTakeSection), | |||
| "Lot / 批號": orDash(e.lotNo), | |||
| "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "", | |||
| "Accepted Qty / 核准數量": | |||
| resolveStockTakeAcceptedQty(d, e.afterQty) ?? "", | |||
| "Variance Qty / 差異數量": | |||
| d?.varianceQty != null ? d.varianceQty : e.varianceQty, | |||
| "Approver / 核准人": orDash(d?.approverName || e.approver), | |||
| "Stock Taker / 初盤人": orDash(d?.stockTakerName), | |||
| "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "", | |||
| "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "", | |||
| "Approver Count Qty / 覆盤數量": d?.approverQty ?? "", | |||
| "Record Status / 紀錄狀態": orDash(d?.recordStatus), | |||
| "Date/Time / 時間": orDash(e.timestamp), | |||
| }; | |||
| }); | |||
| return ensureRows(rows, stockTakeEmpty()); | |||
| }; | |||
| const productionEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": "", | |||
| "Lot / 批號": "", | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": "", | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": "", | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildProductionSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| compiledGraph: CompiledTraceGraph, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const rows: Record<string, unknown>[] = []; | |||
| const presentation = buildTracePresentationRows(compiledGraph.nodes, data); | |||
| const upstreamSource = data.joPrelude?.materialInputs?.length | |||
| ? data.joPrelude.materialInputs.map((m) => ({ | |||
| jobOrderCode: m.jobOrderCode, | |||
| materialItemCode: m.materialItemCode, | |||
| materialLotNo: m.materialLotNo, | |||
| materialQty: m.materialQty, | |||
| materialUom: m.materialUom, | |||
| assignedStepName: m.assignedStepName, | |||
| pickOrderCode: m.pickOrderCode, | |||
| pickedAt: m.pickedAt, | |||
| materialItemName: m.materialItemName, | |||
| })) | |||
| : data.bomTrace.upstream.map((u) => ({ | |||
| jobOrderCode: u.jobOrderCode, | |||
| materialItemCode: u.materialItemCode, | |||
| materialLotNo: u.materialLotNo, | |||
| materialQty: u.materialQty, | |||
| materialUom: "", | |||
| assignedStepName: "", | |||
| pickOrderCode: "", | |||
| pickedAt: null as string | null, | |||
| materialItemName: "", | |||
| })); | |||
| upstreamSource.forEach((m) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Upstream / BOM 上游", | |||
| "Job Order / 工單": orDash(m.jobOrderCode), | |||
| "Doc No. / 單號": orDash(m.pickOrderCode), | |||
| "Item Code / 貨品編號": orDash(m.materialItemCode), | |||
| "Lot / 批號": orDash(m.materialLotNo), | |||
| "Item Name / 品名": orDash(m.materialItemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(m.assignedStepName), | |||
| "Qty / 數量": m.materialQty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(m.materialUom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(m.pickedAt), | |||
| }); | |||
| }); | |||
| data.bomTrace.downstream.forEach((d) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Downstream / BOM 下游", | |||
| "Job Order / 工單": orDash(d.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(d.finishedItemCode), | |||
| "Lot / 批號": orDash(d.finishedLotNo), | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": d.fgQty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(d.fgUom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| }); | |||
| data.bomTrace.bomRecipe.forEach((r) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Recipe / BOM 配方", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(r.materialItemCode), | |||
| "Lot / 批號": "", | |||
| "Item Name / 品名": orDash(r.materialItemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(r.assignedStepName), | |||
| "Qty / 數量": r.qtyPerUnit, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(r.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| }); | |||
| presentation.joPicks | |||
| .slice() | |||
| .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? "")) | |||
| .forEach((p) => { | |||
| rows.push({ | |||
| "Section / 區塊": "JO Pick / 工單提料", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": orDash(p.pickOrderCode), | |||
| "Item Code / 貨品編號": orDash(p.materialItemCode), | |||
| "Lot / 批號": orDash(p.materialLotNo), | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(p.assignedStepName), | |||
| "Qty / 數量": p.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(p.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(p.pickedAt), | |||
| }); | |||
| }); | |||
| const productionRows = | |||
| data.productionSteps.length > 0 | |||
| ? data.productionSteps | |||
| .slice() | |||
| .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) | |||
| .map((s) => ({ | |||
| "Section / 區塊": "Production Step / 生產步驟", | |||
| "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(data.lot.itemCode), | |||
| "Lot / 批號": orDash(data.lot.lotNo), | |||
| "Item Name / 品名": orDash(data.lot.itemName), | |||
| "Step / 步驟": orDash(s.stepName), | |||
| "Assigned Step / 對應製程": orDash(s.description), | |||
| "Qty / 數量": s.outputQty, | |||
| "Scrap Qty / 損耗數量": s.scrapQty, | |||
| "Defect Qty / 不良數量": s.defectQty, | |||
| "UOM / 單位": orDash(data.lot.uom), | |||
| "Operator / 操作員": orDash(s.operatorName), | |||
| "Equipment / 設備": | |||
| [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") || | |||
| "—", | |||
| "Status / 狀態": tr.productionStatus(s.status), | |||
| "Date/Time / 時間": orDash(s.endTime || s.startTime), | |||
| })) | |||
| : presentation.production | |||
| .slice() | |||
| .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) | |||
| .map((s) => ({ | |||
| "Section / 區塊": "Production Step / 生產步驟", | |||
| "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(data.lot.itemCode), | |||
| "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo), | |||
| "Item Name / 品名": orDash(data.lot.itemName), | |||
| "Step / 步驟": orDash(s.stepName), | |||
| "Assigned Step / 對應製程": orDash(s.description), | |||
| "Qty / 數量": s.outputQty, | |||
| "Scrap Qty / 損耗數量": s.scrapQty, | |||
| "Defect Qty / 不良數量": s.defectQty, | |||
| "UOM / 單位": orDash(data.lot.uom), | |||
| "Operator / 操作員": orDash(s.operatorName), | |||
| "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode), | |||
| "Status / 狀態": orDash(s.status), | |||
| "Date/Time / 時間": orDash(s.endTime || s.startTime), | |||
| })); | |||
| rows.push(...productionRows); | |||
| data.byproductLots.forEach((b) => { | |||
| rows.push({ | |||
| "Section / 區塊": "Byproduct / 副產品", | |||
| "Job Order / 工單": orDash(b.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(b.itemCode), | |||
| "Lot / 批號": orDash(b.lotNo), | |||
| "Item Name / 品名": orDash(b.itemName), | |||
| "Step / 步驟": orDash(b.processStepName), | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": b.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(b.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(b.producedAt), | |||
| }); | |||
| }); | |||
| data.lotRelations.forEach((r) => { | |||
| rows.push({ | |||
| "Section / 區塊": "Lot Relation / 拆批關聯", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": orDash(r.relationType), | |||
| "Item Code / 貨品編號": orDash(r.itemCode), | |||
| "Lot / 批號": orDash(r.lotNo), | |||
| "Item Name / 品名": orDash(r.itemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": r.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": "", | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": orDash(r.productLotNo), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| }); | |||
| }); | |||
| return ensureRows(rows, productionEmpty()); | |||
| }; | |||
| /** Build the 8 classified sheets without triggering download (for tests). */ | |||
| export const buildItemLotTraceSheets = ( | |||
| data: ItemLotTraceResponse, | |||
| compiledGraph: CompiledTraceGraph, | |||
| t: TFunction, | |||
| exportLabels: ItemLotTraceExportLabels, | |||
| exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), | |||
| ): MultiSheetSpec[] => { | |||
| const tr = createTraceLabelTranslator(t); | |||
| return [ | |||
| { | |||
| name: SHEET.summary, | |||
| rows: buildSummarySheet(data, exportLabels, exportAt), | |||
| }, | |||
| { name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) }, | |||
| { name: SHEET.origins, rows: buildOriginsSheet(data, tr) }, | |||
| { name: SHEET.qc, rows: buildQcSheet(data, tr) }, | |||
| { name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) }, | |||
| { name: SHEET.outbound, rows: buildOutboundSheet(data, tr) }, | |||
| { name: SHEET.stockTake, rows: buildStockTakeSheet(data) }, | |||
| { | |||
| name: SHEET.production, | |||
| rows: buildProductionSheet(data, compiledGraph, tr), | |||
| }, | |||
| ]; | |||
| }; | |||
| export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 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)); | |||
| }; | |||
| @@ -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"; | |||
| @@ -0,0 +1,113 @@ | |||
| "use client"; | |||
| import { | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableHead, | |||
| TableRow, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { type ReactNode } from "react"; | |||
| export type FilterableColumnDef<T> = { | |||
| key: string; | |||
| label: string; | |||
| align?: "left" | "right" | "center"; | |||
| value: (row: T) => string | number | null | undefined; | |||
| cell?: (row: T) => ReactNode; | |||
| /** @deprecated Unused — filters removed. */ | |||
| filterable?: boolean; | |||
| }; | |||
| type FilterableDataTableProps<T> = { | |||
| rows: T[]; | |||
| columns: FilterableColumnDef<T>[]; | |||
| getRowKey: (row: T, index: number) => string; | |||
| emptyLabel: string; | |||
| size?: "small" | "medium"; | |||
| collapseAfter?: number; | |||
| showAll?: boolean; | |||
| onToggleShowAll?: () => void; | |||
| showAllLabel?: string | ((count: number) => string); | |||
| collapseLabel?: string | ((count: number) => string); | |||
| }; | |||
| const resolveCountLabel = ( | |||
| label: string | ((count: number) => string) | undefined, | |||
| count: number, | |||
| fallback: string, | |||
| ) => (typeof label === "function" ? label(count) : (label ?? fallback)); | |||
| /** Plain data table (column filters removed). */ | |||
| export function FilterableDataTable<T>({ | |||
| rows, | |||
| columns, | |||
| getRowKey, | |||
| emptyLabel, | |||
| size = "small", | |||
| collapseAfter, | |||
| showAll = false, | |||
| onToggleShowAll, | |||
| showAllLabel, | |||
| collapseLabel, | |||
| }: FilterableDataTableProps<T>) { | |||
| const collapsed = | |||
| collapseAfter != null && rows.length > collapseAfter && !showAll; | |||
| const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows; | |||
| return ( | |||
| <> | |||
| <Table size={size}> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((col) => ( | |||
| <TableCell | |||
| key={col.key} | |||
| align={col.align} | |||
| sx={{ fontWeight: 700, whiteSpace: "nowrap" }} | |||
| > | |||
| {col.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {visibleRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={columns.length}> | |||
| <Typography color="text.secondary" variant="body2"> | |||
| {emptyLabel} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| visibleRows.map((row, index) => ( | |||
| <TableRow key={getRowKey(row, index)}> | |||
| {columns.map((col) => ( | |||
| <TableCell key={col.key} align={col.align}> | |||
| {col.cell ? col.cell(row) : (col.value(row) ?? "—")} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| {collapseAfter != null && | |||
| rows.length > collapseAfter && | |||
| onToggleShowAll && ( | |||
| <Typography | |||
| variant="caption" | |||
| color="primary" | |||
| sx={{ cursor: "pointer", mt: 0.5, display: "inline-block" }} | |||
| onClick={onToggleShowAll} | |||
| > | |||
| {showAll | |||
| ? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`) | |||
| : resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)} | |||
| </Typography> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,316 @@ | |||
| import type { | |||
| ItemLotTraceLocationBlock, | |||
| ItemLotTraceResponse, | |||
| } from "@/app/api/itemTracing"; | |||
| import { blockWarehouseCode } from "./buildLocationBlockGraphNodes"; | |||
| export type ScopedRow<T> = T & { | |||
| scopeWarehouseCode: string; | |||
| inventoryLotId: number; | |||
| }; | |||
| export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string => | |||
| data.warehouseLines | |||
| .map((w) => w.warehouseCode) | |||
| .filter(Boolean) | |||
| .join(" / ") || "—"; | |||
| const sortByTimestampDesc = <T extends { timestamp?: string | null }>( | |||
| rows: T[], | |||
| ): T[] => | |||
| [...rows].sort((a, b) => | |||
| (b.timestamp ?? "").localeCompare(a.timestamp ?? ""), | |||
| ); | |||
| const sortByReceiptDateDesc = <T extends { receiptDate?: string | null }>( | |||
| rows: T[], | |||
| ): T[] => | |||
| [...rows].sort((a, b) => | |||
| (b.receiptDate ?? "").localeCompare(a.receiptDate ?? ""), | |||
| ); | |||
| export const mergeScopedOrigins = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.origins.map((o) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.origins.map((o) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| })); | |||
| }); | |||
| return sortByReceiptDateDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedQcResults = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.qcResults.map((q, i) => ({ | |||
| ...q, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${q.stockInLineId}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.qcResults.map((q, i) => ({ | |||
| ...q, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${q.stockInLineId}-${i}`, | |||
| })); | |||
| }); | |||
| return [...primary, ...fromBlocks].sort((a, b) => | |||
| (b.created ?? "").localeCompare(a.created ?? ""), | |||
| ); | |||
| }; | |||
| export const mergeScopedOutboundUsage = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.outboundUsage.map((u) => ({ | |||
| ...u, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.outboundUsage.map((u) => ({ | |||
| ...u, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedStockTakeEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.stockTakeEvents.map((e, i) => ({ | |||
| ...e, | |||
| scopeWarehouseCode: e.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${e.stockTakeCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.stockTakeEvents.map((e, i) => ({ | |||
| ...e, | |||
| scopeWarehouseCode: e.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${e.stockTakeCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedAdjustments = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.adjustments.map((a, i) => ({ | |||
| ...a, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${a.refCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.adjustments.map((a, i) => ({ | |||
| ...a, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${a.refCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedTransfers = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.transfers.map((tr, i) => ({ | |||
| ...tr, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${tr.transferCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.transfers.map((tr, i) => ({ | |||
| ...tr, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${tr.transferCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedMovements = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.movements.map((m, i) => ({ | |||
| ...m, | |||
| scopeWarehouseCode: m.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-mov-${i}-${m.refCode}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.movements.map((m, i) => ({ | |||
| ...m, | |||
| scopeWarehouseCode: m.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-mov-${i}-${m.refCode}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedPutawayEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.putawayEvents.map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: p.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-putaway-${i}-${p.refCode}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.putawayEvents.map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: p.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-putaway-${i}-${p.refCode}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedPurchaseEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = (data.purchaseEvents ?? []).map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-po-${i}-${p.purchaseOrderLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return (block.purchaseEvents ?? []).map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-po-${i}-${p.purchaseOrderLineId}`, | |||
| })); | |||
| }); | |||
| return [...primary, ...fromBlocks].sort((a, b) => | |||
| (b.orderDate ?? "").localeCompare(a.orderDate ?? ""), | |||
| ); | |||
| }; | |||
| export const mergeScopedDoDeliveries = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.doDeliveries.map((d, i) => ({ | |||
| ...d, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-do-${i}-${d.stockOutLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.doDeliveries.map((d, i) => ({ | |||
| ...d, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-do-${i}-${d.stockOutLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedFailEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.failEvents.map((f, i) => ({ | |||
| ...f, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-fail-${i}-${f.failId}`, | |||
| timestamp: f.recordDate, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.failEvents.map((f, i) => ({ | |||
| ...f, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-fail-${i}-${f.failId}`, | |||
| timestamp: f.recordDate, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedReturnEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.returnEvents.map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: r.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-return-${i}-${r.stockOutLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.returnEvents.map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: r.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-return-${i}-${r.stockOutLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedOpenMovements = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.openMovements.map((o, i) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: o.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-open-${i}-${o.stockInLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.openMovements.map((o, i) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: o.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-open-${i}-${o.stockInLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedReplenishmentEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = (data.replenishmentEvents ?? []).map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-replenish-${i}-${r.replenishmentId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return (block.replenishmentEvents ?? []).map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-replenish-${i}-${r.replenishmentId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const hasMultipleLocations = (data: ItemLotTraceResponse): boolean => | |||
| (data.locationBlocks?.length ?? 0) > 0 || | |||
| (data.alternateLocations?.length ?? 0) > 0; | |||
| @@ -0,0 +1,274 @@ | |||
| import { | |||
| DO_GROUP_CHILD_WIDTH, | |||
| DO_GROUP_HEADER, | |||
| DO_GROUP_INNER_COLS_MAX, | |||
| DO_GROUP_MIN_SIZE, | |||
| DO_GROUP_PAD, | |||
| PICK_GROUP_MIN_SIZE, | |||
| NODE_GAP, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| } from "./traceFlowConstants"; | |||
| import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; | |||
| import { TraceGraphNode } from "./buildTraceGraphNodes"; | |||
| export type DoGroupChildPlacement = { x: number; y: number; compact: boolean }; | |||
| /** Fewer columns for large groups — wider cards, no horizontal cramming. */ | |||
| const preferredDoGroupCols = (memberCount: number): number => { | |||
| if (memberCount <= 1) return 1; | |||
| if (memberCount >= 15) return 2; | |||
| if (memberCount >= 6) return 3; | |||
| return 2; | |||
| }; | |||
| const sumDoGroupQty = ( | |||
| children: TraceGraphLayoutNode[], | |||
| ): { totalQty: number; uom?: string } | null => { | |||
| let totalQty = 0; | |||
| let hasQty = false; | |||
| let uom: string | undefined; | |||
| children.forEach((child) => { | |||
| const qty = child.qty; | |||
| if (qty != null && Number.isFinite(Number(qty))) { | |||
| totalQty += Number(qty); | |||
| hasQty = true; | |||
| } | |||
| if (!uom && child.uom?.trim()) uom = child.uom.trim(); | |||
| }); | |||
| return hasQty ? { totalQty, uom } : null; | |||
| }; | |||
| export const computeDoGroupBoxLayout = ( | |||
| memberCount: number, | |||
| ): { width: number; height: number; cols: number } => { | |||
| const childW = DO_GROUP_CHILD_WIDTH; | |||
| const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount)); | |||
| const rows = Math.ceil(memberCount / cols); | |||
| const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP; | |||
| const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP; | |||
| return { | |||
| width: gridW + DO_GROUP_PAD * 2, | |||
| height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2, | |||
| cols, | |||
| }; | |||
| }; | |||
| export const layoutDoGroupChildPlacements = ( | |||
| children: TraceGraphLayoutNode[], | |||
| ): Map<string, DoGroupChildPlacement> => { | |||
| const sorted = [...children].sort(sortNodesInPhase); | |||
| const { cols } = computeDoGroupBoxLayout(sorted.length); | |||
| const childW = DO_GROUP_CHILD_WIDTH; | |||
| const result = new Map<string, DoGroupChildPlacement>(); | |||
| sorted.forEach((child, index) => { | |||
| const col = index % cols; | |||
| const row = Math.floor(index / cols); | |||
| result.set(child.id, { | |||
| x: DO_GROUP_PAD + col * (childW + NODE_GAP), | |||
| y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP), | |||
| compact: false, | |||
| }); | |||
| }); | |||
| return result; | |||
| }; | |||
| export const applyDoOutboundGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| groupTitle: (count: number) => string, | |||
| minSize = DO_GROUP_MIN_SIZE, | |||
| ): TraceGraphLayoutNode[] => { | |||
| const doByColumnAndWarehouse = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((node) => { | |||
| if (node.kind !== "DO_OUT" || node.doGroupId) return; | |||
| const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__"; | |||
| const key = `${node.column}::${whKey}`; | |||
| const list = doByColumnAndWarehouse.get(key) ?? []; | |||
| list.push(node); | |||
| doByColumnAndWarehouse.set(key, list); | |||
| }); | |||
| const groupNodes: TraceGraphLayoutNode[] = []; | |||
| const childGroupIds = new Map<string, string>(); | |||
| doByColumnAndWarehouse.forEach((columnNodes, key) => { | |||
| if (columnNodes.length < minSize) return; | |||
| const column = columnNodes[0]!.column; | |||
| const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined; | |||
| const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_"); | |||
| const groupId = `do-group-col-${column}-${whSlug}`; | |||
| const sorted = [...columnNodes].sort(sortNodesInPhase); | |||
| const { width, height } = computeDoGroupBoxLayout(sorted.length); | |||
| const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); | |||
| const qtySummary = sumDoGroupQty(sorted); | |||
| sorted.forEach((child) => childGroupIds.set(child.id, groupId)); | |||
| const groupNode: TraceGraphLayoutNode = { | |||
| id: groupId, | |||
| kind: "DO_GROUP", | |||
| timestamp: sorted[0]?.timestamp ?? null, | |||
| sortKey: minSort, | |||
| title: groupTitle(sorted.length), | |||
| subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "", | |||
| categoryLabel: sorted[0]?.categoryLabel, | |||
| warehouseCode, | |||
| details: [], | |||
| phase: sorted[0]!.phase, | |||
| column, | |||
| dayKey: sorted[0]!.dayKey, | |||
| laneIndex: sorted[0]!.laneIndex, | |||
| sequenceIndex: sorted[0]!.sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, | |||
| groupBoxWidth: width, | |||
| groupBoxHeight: height, | |||
| groupMemberCount: sorted.length, | |||
| groupTotalQty: qtySummary?.totalQty, | |||
| groupUom: qtySummary?.uom, | |||
| }; | |||
| groupNodes.push(groupNode); | |||
| }); | |||
| if (!groupNodes.length) return nodes; | |||
| return [ | |||
| ...nodes.map((node) => { | |||
| const groupId = childGroupIds.get(node.id); | |||
| if (!groupId) return node; | |||
| return { ...node, doGroupId: groupId }; | |||
| }), | |||
| ...groupNodes, | |||
| ].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| export const applyMaterialPickGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| groupTitle: (pickOrderCode: string, count: number) => string, | |||
| minSize = PICK_GROUP_MIN_SIZE, | |||
| ): TraceGraphLayoutNode[] => { | |||
| const picksByKey = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((node) => { | |||
| if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return; | |||
| const pickCode = node.refCode?.trim(); | |||
| if (!pickCode) return; | |||
| const key = `${node.column}::${pickCode}`; | |||
| const list = picksByKey.get(key) ?? []; | |||
| list.push(node); | |||
| picksByKey.set(key, list); | |||
| }); | |||
| const groupNodes: TraceGraphLayoutNode[] = []; | |||
| const childGroupIds = new Map<string, string>(); | |||
| picksByKey.forEach((columnNodes, key) => { | |||
| if (columnNodes.length < minSize) return; | |||
| const pickCode = key.split("::").slice(1).join("::"); | |||
| const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`; | |||
| const sorted = [...columnNodes].sort(sortNodesInPhase); | |||
| const { width, height } = computeDoGroupBoxLayout(sorted.length); | |||
| const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); | |||
| sorted.forEach((child) => childGroupIds.set(child.id, groupId)); | |||
| const groupNode: TraceGraphLayoutNode = { | |||
| id: groupId, | |||
| kind: "PICK_GROUP", | |||
| timestamp: sorted[0]?.timestamp ?? null, | |||
| sortKey: minSort, | |||
| title: groupTitle(pickCode, sorted.length), | |||
| subtitle: sorted[0]?.dayKey ?? "", | |||
| refCode: pickCode, | |||
| refId: sorted[0]?.refId, | |||
| docLinkKind: "pick", | |||
| consoCode: sorted[0]?.consoCode, | |||
| jobOrderCode: sorted[0]?.jobOrderCode, | |||
| categoryLabel: sorted[0]?.categoryLabel, | |||
| details: [], | |||
| phase: sorted[0]!.phase, | |||
| column: sorted[0]!.column, | |||
| dayKey: sorted[0]!.dayKey, | |||
| laneIndex: sorted[0]!.laneIndex, | |||
| sequenceIndex: sorted[0]!.sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, | |||
| groupBoxWidth: width, | |||
| groupBoxHeight: height, | |||
| groupMemberCount: sorted.length, | |||
| }; | |||
| groupNodes.push(groupNode); | |||
| }); | |||
| if (!groupNodes.length) return nodes; | |||
| return [ | |||
| ...nodes.map((node) => { | |||
| const groupId = childGroupIds.get(node.id); | |||
| if (!groupId) return node; | |||
| return { ...node, doGroupId: groupId }; | |||
| }), | |||
| ...groupNodes, | |||
| ].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| export const applyFlowNodeGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| labels: { | |||
| flowDoGroupTitle: (count: number) => string; | |||
| flowPickGroupTitle: (pickOrderCode: string, count: number) => string; | |||
| }, | |||
| ): TraceGraphLayoutNode[] => | |||
| applyMaterialPickGrouping( | |||
| applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle), | |||
| labels.flowPickGroupTitle, | |||
| ); | |||
| export const regroupLayoutCells = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| ): TraceGraphLayoutNode[][] => { | |||
| const visible = nodes.filter((n) => !isDoGroupChild(n)); | |||
| const groups = new Map<string, TraceGraphLayoutNode[]>(); | |||
| visible.forEach((node) => { | |||
| const key = `${node.column}:${node.laneIndex}`; | |||
| const list = groups.get(key) ?? []; | |||
| list.push(node); | |||
| groups.set(key, list); | |||
| }); | |||
| groups.forEach((list) => { | |||
| list.sort(sortNodesInPhase); | |||
| const size = list.length; | |||
| list.forEach((node, idx) => { | |||
| node.branchIndex = idx; | |||
| node.branchSize = size; | |||
| }); | |||
| }); | |||
| return Array.from(groups.values()).sort((a, b) => { | |||
| const na = a[0]!; | |||
| const nb = b[0]!; | |||
| if (na.column !== nb.column) return na.column - nb.column; | |||
| return na.laneIndex - nb.laneIndex; | |||
| }); | |||
| }; | |||
| export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => | |||
| node.kind === "DO_GROUP" || node.kind === "PICK_GROUP"; | |||
| export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => | |||
| Boolean(node.doGroupId?.trim()); | |||
| @@ -0,0 +1,155 @@ | |||
| import dayjs from "dayjs"; | |||
| import { | |||
| ItemLotTraceJoPrelude, | |||
| ItemLotTraceResponse, | |||
| } from "@/app/api/itemTracing"; | |||
| export const isWorkbenchTicketNo = (ticketNo?: string | null): boolean => | |||
| (ticketNo ?? "").trim().toUpperCase().startsWith("TI-"); | |||
| /** Normalize any date/datetime string to YYYY-MM-DD for API / URL query params (LocalDate). */ | |||
| export const normalizeTargetDateForLink = ( | |||
| value?: string | null, | |||
| ): string | undefined => { | |||
| if (value == null) return undefined; | |||
| const raw = String(value).trim(); | |||
| if (!raw) return undefined; | |||
| // Prefer extracting YYYY-MM-DD prefix — dayjs/Date.parse is unreliable for | |||
| // "YYYY-MM-DD HH:mm:ss" in some browsers (e.g. Safari). | |||
| const match = raw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| if (match) return match[1]; | |||
| const d = dayjs(raw); | |||
| return d.isValid() ? d.format("YYYY-MM-DD") : undefined; | |||
| }; | |||
| export const targetDateFromTraceTimestamp = ( | |||
| timestamp?: string | null, | |||
| ): string | undefined => normalizeTargetDateForLink(timestamp); | |||
| export type DoOutboundDocLink = { | |||
| kind: "workbench" | "pick" | undefined; | |||
| ticketNo?: string; | |||
| targetDate?: string; | |||
| /** Visible link label (DO code preferred on DO_OUT cards). */ | |||
| displayCode: string; | |||
| pickOrderId?: number | null; | |||
| consoCode?: string; | |||
| }; | |||
| export type JoPickDocLink = { | |||
| kind: "jodetail"; | |||
| pickOrderCode: string; | |||
| targetDate?: string; | |||
| displayCode: string; | |||
| pickOrderId?: number | null; | |||
| }; | |||
| const walkJoPreludePickOrders = ( | |||
| prelude: ItemLotTraceJoPrelude, | |||
| map: Map<string, string>, | |||
| ): void => { | |||
| prelude.pickOrders.forEach((po) => { | |||
| const code = po.pickOrderCode?.trim(); | |||
| const date = normalizeTargetDateForLink(po.targetDate); | |||
| if (code && date) map.set(code, date); | |||
| }); | |||
| prelude.materialInputs.forEach((m) => { | |||
| if (m.nestedJoPrelude) walkJoPreludePickOrders(m.nestedJoPrelude, map); | |||
| }); | |||
| }; | |||
| export const buildPickOrderTargetDateMapFromPrelude = ( | |||
| prelude: ItemLotTraceJoPrelude, | |||
| ): Map<string, string> => { | |||
| const map = new Map<string, string>(); | |||
| walkJoPreludePickOrders(prelude, map); | |||
| return map; | |||
| }; | |||
| export const buildPickOrderTargetDateMap = ( | |||
| data: ItemLotTraceResponse, | |||
| ): Map<string, string> => | |||
| data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map(); | |||
| export const resolvePickOrderTargetDate = ( | |||
| map: Map<string, string>, | |||
| pickOrderCode?: string | null, | |||
| fallbackTimestamp?: string | null, | |||
| ): string | undefined => { | |||
| const code = pickOrderCode?.trim(); | |||
| if (code) { | |||
| const fromPrelude = normalizeTargetDateForLink(map.get(code)); | |||
| if (fromPrelude) return fromPrelude; | |||
| } | |||
| return targetDateFromTraceTimestamp(fallbackTimestamp); | |||
| }; | |||
| export const resolveJoPickDocLink = (input: { | |||
| pickOrderCode?: string; | |||
| pickOrderId?: number | null; | |||
| timestamp?: string | null; | |||
| targetDate?: string | null; | |||
| }): JoPickDocLink | null => { | |||
| const pickCode = input.pickOrderCode?.trim() || ""; | |||
| if (!pickCode) return null; | |||
| return { | |||
| kind: "jodetail", | |||
| pickOrderCode: pickCode, | |||
| targetDate: | |||
| normalizeTargetDateForLink(input.targetDate) || | |||
| targetDateFromTraceTimestamp(input.timestamp), | |||
| displayCode: pickCode, | |||
| pickOrderId: input.pickOrderId, | |||
| }; | |||
| }; | |||
| export const resolveDoOutboundDocLink = (input: { | |||
| pickOrderCode?: string; | |||
| pickOrderId?: number | null; | |||
| deliveryOrderCode?: string; | |||
| ticketNo?: string; | |||
| outboundTicketNo?: string; | |||
| consoCode?: string; | |||
| timestamp?: string | null; | |||
| /** Workbench path only when linked to delivery_order_pick_order. */ | |||
| deliveryOrderPickOrderId?: number | null; | |||
| }): DoOutboundDocLink => { | |||
| const ticket = (input.ticketNo || input.outboundTicketNo || "").trim(); | |||
| const conso = (input.consoCode || "").trim(); | |||
| const hasWorkbenchLink = | |||
| input.deliveryOrderPickOrderId != null && | |||
| Number.isFinite(Number(input.deliveryOrderPickOrderId)); | |||
| const workbenchTicket = hasWorkbenchLink | |||
| ? isWorkbenchTicketNo(ticket) | |||
| ? ticket | |||
| : isWorkbenchTicketNo(conso) | |||
| ? conso | |||
| : undefined | |||
| : undefined; | |||
| const targetDate = targetDateFromTraceTimestamp(input.timestamp); | |||
| const pickCode = input.pickOrderCode?.trim() || ""; | |||
| const doCode = input.deliveryOrderCode?.trim() || ""; | |||
| if (workbenchTicket) { | |||
| return { | |||
| kind: "workbench", | |||
| ticketNo: workbenchTicket, | |||
| targetDate, | |||
| displayCode: doCode || pickCode || workbenchTicket, | |||
| pickOrderId: input.pickOrderId, | |||
| consoCode: conso || pickCode, | |||
| }; | |||
| } | |||
| // Prefer 送貨單號 on the card; /pickOrder/detail is retired so no pick-only link. | |||
| // Legacy GoodPick TI-* without deliveryOrderPickOrderId must not deep-link to workbench. | |||
| if (doCode || pickCode) { | |||
| return { | |||
| kind: undefined, | |||
| displayCode: doCode || pickCode, | |||
| pickOrderId: input.pickOrderId, | |||
| consoCode: conso || pickCode, | |||
| targetDate, | |||
| }; | |||
| } | |||
| return { kind: undefined, displayCode: "—" }; | |||
| }; | |||
| @@ -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; | |||
| @@ -0,0 +1,395 @@ | |||
| export type TraceFlowPair = { fromId: string; toId: string }; | |||
| export type RoutedTraceFlowEdge = TraceFlowPair & { | |||
| sourceHandle: string; | |||
| targetHandle: string; | |||
| offset: number; | |||
| pathMode?: "smooth" | "corridor"; | |||
| corridorY?: number; | |||
| /** Added to React Flow sourceX for the first horizontal stub. */ | |||
| corridorExitOffset?: number; | |||
| /** Added to React Flow targetX for the vertical drop into the target. */ | |||
| corridorEntryOffset?: number; | |||
| /** | |||
| * Added to React Flow sourceX for the late branch point on the corridor. | |||
| * Shared-trunk fans travel together until this X, then fork to each target. | |||
| */ | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| export type TraceFlowNodeRect = { | |||
| x: number; | |||
| y: number; | |||
| width: number; | |||
| height: number; | |||
| laneIndex: number; | |||
| }; | |||
| export type TraceFlowLaneLayout = { | |||
| laneTops: number[]; | |||
| laneHeights: number[]; | |||
| laneGap: number; | |||
| }; | |||
| const OFFSET_STEP = 18; | |||
| const CORRIDOR_STEP = 14; | |||
| const HORIZONTAL_STUB = 14; | |||
| const MIN_HORIZONTAL_SPAN = 24; | |||
| export const TRACE_FLOW_HANDLE_IN = "in"; | |||
| export const TRACE_FLOW_HANDLE_OUT = "out"; | |||
| const stableSortPairs = (list: TraceFlowPair[]): TraceFlowPair[] => | |||
| [...list].sort((a, b) => { | |||
| const keyA = `${a.fromId}->${a.toId}`; | |||
| const keyB = `${b.fromId}->${b.toId}`; | |||
| return keyA.localeCompare(keyB); | |||
| }); | |||
| const edgeKey = (fromId: string, toId: string) => `${fromId}->${toId}`; | |||
| const targetColumnKey = (rect: TraceFlowNodeRect) => | |||
| `${rect.laneIndex}:${Math.round(rect.x)}`; | |||
| const sourceColumnKey = (rect: TraceFlowNodeRect) => | |||
| `${rect.laneIndex}:${Math.round(rect.x)}`; | |||
| type CorridorMeta = { | |||
| corridorY: number; | |||
| corridorExitOffset: number; | |||
| corridorEntryOffset: number; | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| /** Spread multiple edges from the same node via handles + smoothstep offset. */ | |||
| export const assignTraceFlowEdgeRouting = ( | |||
| pairs: TraceFlowPair[], | |||
| ): { | |||
| routed: RoutedTraceFlowEdge[]; | |||
| incomingCount: Map<string, number>; | |||
| outgoingCount: Map<string, number>; | |||
| } => { | |||
| const bySource = new Map<string, TraceFlowPair[]>(); | |||
| const byTarget = new Map<string, TraceFlowPair[]>(); | |||
| pairs.forEach((p) => { | |||
| const outList = bySource.get(p.fromId) ?? []; | |||
| outList.push(p); | |||
| bySource.set(p.fromId, outList); | |||
| const inList = byTarget.get(p.toId) ?? []; | |||
| inList.push(p); | |||
| byTarget.set(p.toId, inList); | |||
| }); | |||
| bySource.forEach((list, id) => bySource.set(id, stableSortPairs(list))); | |||
| byTarget.forEach((list, id) => byTarget.set(id, stableSortPairs(list))); | |||
| const incomingCount = new Map<string, number>(); | |||
| const outgoingCount = new Map<string, number>(); | |||
| byTarget.forEach((list, id) => incomingCount.set(id, list.length)); | |||
| // 1→N fans use a single source handle (shared trunk); report 1 outbound slot. | |||
| bySource.forEach((list, id) => outgoingCount.set(id, list.length > 1 ? 1 : list.length)); | |||
| const routed: RoutedTraceFlowEdge[] = pairs.map((p) => { | |||
| const outList = bySource.get(p.fromId) ?? [p]; | |||
| const inList = byTarget.get(p.toId) ?? [p]; | |||
| const sourceIndex = outList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); | |||
| const targetIndex = inList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); | |||
| // 1→N fans leave from one handle so the stroke starts as a single trunk. | |||
| const sourceHandle = | |||
| outList.length > 1 | |||
| ? `${TRACE_FLOW_HANDLE_OUT}-0` | |||
| : `${TRACE_FLOW_HANDLE_OUT}-${Math.max(0, sourceIndex)}`; | |||
| const targetHandle = `${TRACE_FLOW_HANDLE_IN}-${Math.max(0, targetIndex)}`; | |||
| let offset = 0; | |||
| if (outList.length > 1) { | |||
| offset = 0; | |||
| } else if (inList.length > 1) { | |||
| offset = (targetIndex - (inList.length - 1) / 2) * OFFSET_STEP; | |||
| } | |||
| return { ...p, sourceHandle, targetHandle, offset, pathMode: "smooth" }; | |||
| }); | |||
| return { routed, incomingCount, outgoingCount }; | |||
| }; | |||
| const baseCorridorY = ( | |||
| src: TraceFlowNodeRect, | |||
| srcLane: number, | |||
| tgtLane: number, | |||
| lanes: TraceFlowLaneLayout, | |||
| ): number => { | |||
| if (srcLane !== tgtLane) { | |||
| const srcLaneBottom = | |||
| (lanes.laneTops[srcLane] ?? src.y) + (lanes.laneHeights[srcLane] ?? 0); | |||
| return srcLaneBottom + lanes.laneGap * 0.42; | |||
| } | |||
| return src.y + src.height + 12; | |||
| }; | |||
| /** | |||
| * Route long horizontal spans through the lane gutter so edges do not cut across | |||
| * unrelated nodes in the same swimlane. | |||
| * 1→N source fans share one corridor trunk and only branch near targets. | |||
| */ | |||
| export const enrichTraceFlowCorridorRouting = ( | |||
| routed: RoutedTraceFlowEdge[], | |||
| rects: Map<string, TraceFlowNodeRect>, | |||
| lanes: TraceFlowLaneLayout, | |||
| ): RoutedTraceFlowEdge[] => { | |||
| const outDegree = new Map<string, number>(); | |||
| routed.forEach((edge) => { | |||
| outDegree.set(edge.fromId, (outDegree.get(edge.fromId) ?? 0) + 1); | |||
| }); | |||
| const corridorCandidates: RoutedTraceFlowEdge[] = []; | |||
| routed.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| const tgt = rects.get(edge.toId); | |||
| if (!src || !tgt) return; | |||
| const srcRight = src.x + src.width; | |||
| const crossLane = src.laneIndex !== tgt.laneIndex; | |||
| const longSpan = tgt.x > srcRight + MIN_HORIZONTAL_SPAN; | |||
| const sourceFan = (outDegree.get(edge.fromId) ?? 0) > 1; | |||
| if (crossLane || longSpan || sourceFan) { | |||
| corridorCandidates.push(edge); | |||
| } | |||
| }); | |||
| const corridorMetaByKey = new Map<string, CorridorMeta>(); | |||
| const groups = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| const tgt = rects.get(edge.toId); | |||
| if (!src || !tgt) return; | |||
| const groupKey = `${src.laneIndex}->${tgt.laneIndex}`; | |||
| const list = groups.get(groupKey) ?? []; | |||
| list.push(edge); | |||
| groups.set(groupKey, list); | |||
| }); | |||
| groups.forEach((edges, groupKey) => { | |||
| const [srcLaneStr, tgtLaneStr] = groupKey.split("->"); | |||
| const srcLane = Number(srcLaneStr); | |||
| const tgtLane = Number(tgtLaneStr); | |||
| const byFrom = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| edges.forEach((edge) => { | |||
| const list = byFrom.get(edge.fromId) ?? []; | |||
| list.push(edge); | |||
| byFrom.set(edge.fromId, list); | |||
| }); | |||
| // Distinct sources in this lane-pair get separated corridor Y bands. | |||
| const sourceIds = Array.from(byFrom.keys()).sort((a, b) => { | |||
| const ax = rects.get(a)?.x ?? 0; | |||
| const bx = rects.get(b)?.x ?? 0; | |||
| if (ax !== bx) return ax - bx; | |||
| const ay = rects.get(a)?.y ?? 0; | |||
| const by = rects.get(b)?.y ?? 0; | |||
| return ay - by || a.localeCompare(b); | |||
| }); | |||
| sourceIds.forEach((fromId, sourceIndex) => { | |||
| const fan = [...(byFrom.get(fromId) ?? [])].sort((a, b) => { | |||
| const aty = rects.get(a.toId)?.y ?? 0; | |||
| const bty = rects.get(b.toId)?.y ?? 0; | |||
| if (aty !== bty) return aty - bty; | |||
| const atx = rects.get(a.toId)?.x ?? 0; | |||
| const btx = rects.get(b.toId)?.x ?? 0; | |||
| return ( | |||
| atx - btx || | |||
| edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)) | |||
| ); | |||
| }); | |||
| const src = rects.get(fromId); | |||
| if (!src || !fan.length) return; | |||
| const baseY = baseCorridorY(src, srcLane, tgtLane, lanes); | |||
| const corridorY = baseY + sourceIndex * CORRIDOR_STEP; | |||
| const sharedExit = HORIZONTAL_STUB; | |||
| const srcRight = src.x + src.width; | |||
| const isFan = fan.length > 1; | |||
| const entrySpread = isFan ? (fan.length - 1) * CORRIDOR_STEP : 0; | |||
| const fanMetas = fan.map((edge, fanIndex) => { | |||
| const tgt = rects.get(edge.toId); | |||
| const corridorEntryOffset = isFan | |||
| ? -(HORIZONTAL_STUB + entrySpread) + fanIndex * CORRIDOR_STEP | |||
| : -HORIZONTAL_STUB; | |||
| const entryXLayout = (tgt?.x ?? 0) + corridorEntryOffset; | |||
| return { edge, corridorEntryOffset, entryXLayout }; | |||
| }); | |||
| let sharedBranchOffset: number | undefined; | |||
| if (isFan && fanMetas.length) { | |||
| const minEntryX = Math.min(...fanMetas.map((m) => m.entryXLayout)); | |||
| sharedBranchOffset = minEntryX - srcRight; | |||
| } | |||
| fanMetas.forEach(({ edge, corridorEntryOffset }) => { | |||
| corridorMetaByKey.set(edgeKey(edge.fromId, edge.toId), { | |||
| corridorY, | |||
| corridorExitOffset: sharedExit, | |||
| corridorEntryOffset, | |||
| corridorBranchOffset: sharedBranchOffset, | |||
| }); | |||
| }); | |||
| }); | |||
| }); | |||
| // Separate vertical drops into the same target column (stacked nodes), | |||
| // without breaking shared-trunk exit / branch for same-source fans. | |||
| const byTargetColumn = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const tgt = rects.get(edge.toId); | |||
| if (!tgt) return; | |||
| const key = targetColumnKey(tgt); | |||
| const list = byTargetColumn.get(key) ?? []; | |||
| list.push(edge); | |||
| byTargetColumn.set(key, list); | |||
| }); | |||
| byTargetColumn.forEach((edges) => { | |||
| if (edges.length <= 1) return; | |||
| const sorted = [...edges].sort((a, b) => { | |||
| const aty = rects.get(a.toId)?.y ?? 0; | |||
| const bty = rects.get(b.toId)?.y ?? 0; | |||
| return aty - bty || edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)); | |||
| }); | |||
| const spread = (sorted.length - 1) * CORRIDOR_STEP; | |||
| sorted.forEach((edge, index) => { | |||
| const key = edgeKey(edge.fromId, edge.toId); | |||
| const meta = corridorMetaByKey.get(key); | |||
| if (!meta) return; | |||
| // Same-source fans already have entry offsets relative to the fan. | |||
| if ((outDegree.get(edge.fromId) ?? 0) > 1) return; | |||
| corridorMetaByKey.set(key, { | |||
| ...meta, | |||
| corridorEntryOffset: -(HORIZONTAL_STUB + spread) + index * CORRIDOR_STEP, | |||
| }); | |||
| }); | |||
| }); | |||
| // Separate trunks when multiple distinct sources share a column (stacked nodes). | |||
| // Same-source fan edges keep a shared exit; different sources get staggered exits. | |||
| const bySourceColumn = new Map<string, string[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| if (!src) return; | |||
| const key = sourceColumnKey(src); | |||
| const list = bySourceColumn.get(key) ?? []; | |||
| if (!list.includes(edge.fromId)) list.push(edge.fromId); | |||
| bySourceColumn.set(key, list); | |||
| }); | |||
| bySourceColumn.forEach((sourceIds) => { | |||
| if (sourceIds.length <= 1) return; | |||
| const sortedSources = [...sourceIds].sort((a, b) => { | |||
| const asy = rects.get(a)?.y ?? 0; | |||
| const bsy = rects.get(b)?.y ?? 0; | |||
| return asy - bsy || a.localeCompare(b); | |||
| }); | |||
| const spread = (sortedSources.length - 1) * CORRIDOR_STEP; | |||
| sortedSources.forEach((fromId, index) => { | |||
| const exitOffset = HORIZONTAL_STUB - spread / 2 + index * CORRIDOR_STEP; | |||
| corridorCandidates | |||
| .filter((e) => e.fromId === fromId) | |||
| .forEach((edge) => { | |||
| const key = edgeKey(edge.fromId, edge.toId); | |||
| const meta = corridorMetaByKey.get(key); | |||
| if (!meta) return; | |||
| corridorMetaByKey.set(key, { | |||
| ...meta, | |||
| corridorExitOffset: exitOffset, | |||
| }); | |||
| }); | |||
| }); | |||
| }); | |||
| return routed.map((edge) => { | |||
| const meta = corridorMetaByKey.get(edgeKey(edge.fromId, edge.toId)); | |||
| if (!meta) return edge; | |||
| return { | |||
| ...edge, | |||
| pathMode: "corridor", | |||
| corridorY: meta.corridorY, | |||
| corridorExitOffset: meta.corridorExitOffset, | |||
| corridorEntryOffset: meta.corridorEntryOffset, | |||
| corridorBranchOffset: meta.corridorBranchOffset, | |||
| // Shared trunk fans already leave from out-0 with zero smooth offset. | |||
| offset: (outDegree.get(edge.fromId) ?? 0) > 1 ? 0 : edge.offset, | |||
| sourceHandle: | |||
| (outDegree.get(edge.fromId) ?? 0) > 1 | |||
| ? `${TRACE_FLOW_HANDLE_OUT}-0` | |||
| : edge.sourceHandle, | |||
| }; | |||
| }); | |||
| }; | |||
| /** | |||
| * Orthogonal path: exit source → drop to corridor → travel → | |||
| * (optional late branch) → rise to target. | |||
| */ | |||
| export const buildTraceFlowCorridorPath = ( | |||
| sourceX: number, | |||
| sourceY: number, | |||
| targetX: number, | |||
| targetY: number, | |||
| corridorY: number, | |||
| corridorEntryOffset?: number, | |||
| corridorExitOffset?: number, | |||
| corridorBranchOffset?: number, | |||
| ): string => { | |||
| const exitX = sourceX + (corridorExitOffset ?? HORIZONTAL_STUB); | |||
| const entryX = targetX + (corridorEntryOffset ?? -HORIZONTAL_STUB); | |||
| const midY = corridorY; | |||
| const branchX = | |||
| corridorBranchOffset != null ? sourceX + corridorBranchOffset : entryX; | |||
| // Keep branch between exit and entry so the trunk never backtracks oddly. | |||
| const clampedBranchX = Math.min( | |||
| Math.max(branchX, Math.min(exitX, entryX)), | |||
| Math.max(exitX, entryX), | |||
| ); | |||
| if (Math.abs(clampedBranchX - entryX) < 0.5) { | |||
| return [ | |||
| `M ${sourceX} ${sourceY}`, | |||
| `L ${exitX} ${sourceY}`, | |||
| `L ${exitX} ${midY}`, | |||
| `L ${entryX} ${midY}`, | |||
| `L ${entryX} ${targetY}`, | |||
| `L ${targetX} ${targetY}`, | |||
| ].join(" "); | |||
| } | |||
| return [ | |||
| `M ${sourceX} ${sourceY}`, | |||
| `L ${exitX} ${sourceY}`, | |||
| `L ${exitX} ${midY}`, | |||
| `L ${clampedBranchX} ${midY}`, | |||
| `L ${entryX} ${midY}`, | |||
| `L ${entryX} ${targetY}`, | |||
| `L ${targetX} ${targetY}`, | |||
| ].join(" "); | |||
| }; | |||
| /** Vertical position (%) for handle index among `count` siblings on one node side. */ | |||
| export const traceFlowHandleTopPercent = (index: number, count: number): string => { | |||
| if (count <= 1) return "50%"; | |||
| return `${((index + 1) / (count + 1)) * 100}%`; | |||
| }; | |||
| @@ -0,0 +1,200 @@ | |||
| import { | |||
| COLUMN_WIDTH_MIN, | |||
| HEADER_HEIGHT, | |||
| LANE_GAP, | |||
| LANE_MIN_HEIGHT, | |||
| NODE_GAP, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| NODE_WIDTH_BRANCH, | |||
| } from "./traceFlowConstants"; | |||
| import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; | |||
| import { isFlowGroupContainer } from "./traceDoGroupLayout"; | |||
| export const CELL_PAD_X = 10; | |||
| export const CELL_PAD_Y = 10; | |||
| export type CellPlacement = { x: number; y: number; compact: boolean }; | |||
| const horizWidth = (count: number, nodeW: number) => | |||
| count * nodeW + Math.max(0, count - 1) * NODE_GAP; | |||
| const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2; | |||
| const nodeLayoutSize = ( | |||
| node: TraceGraphLayoutNode, | |||
| compact = false, | |||
| ): { width: number; height: number } => { | |||
| if (isFlowGroupContainer(node)) { | |||
| return { | |||
| width: node.groupBoxWidth ?? NODE_WIDTH, | |||
| height: node.groupBoxHeight ?? NODE_HEIGHT, | |||
| }; | |||
| } | |||
| return { | |||
| width: compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| }; | |||
| }; | |||
| const stackedCellHeight = (members: TraceGraphLayoutNode[]): number => | |||
| members.reduce((sum, node, index) => { | |||
| const { height } = nodeLayoutSize(node); | |||
| return sum + height + (index > 0 ? NODE_GAP : 0); | |||
| }, 0); | |||
| /** Group boxes have variable height — never tile them horizontally with other cards. */ | |||
| const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean => | |||
| members.length > 1 && members.some(isFlowGroupContainer); | |||
| const layoutStackedMembers = ( | |||
| sorted: TraceGraphLayoutNode[], | |||
| innerW: number, | |||
| compact: boolean, | |||
| ): Map<string, CellPlacement> => { | |||
| const result = new Map<string, CellPlacement>(); | |||
| let y = CELL_PAD_Y; | |||
| sorted.forEach((node) => { | |||
| const { width, height } = nodeLayoutSize(node, compact); | |||
| result.set(node.id, { | |||
| x: Math.max(0, (innerW - width) / 2), | |||
| y, | |||
| compact, | |||
| }); | |||
| y += height + NODE_GAP; | |||
| }); | |||
| return result; | |||
| }; | |||
| /** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */ | |||
| export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const n = sorted.length; | |||
| if (n === 0) return COLUMN_WIDTH_MIN; | |||
| if (n === 1 && isFlowGroupContainer(sorted[0]!)) { | |||
| return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2); | |||
| } | |||
| if (n === 1) return COLUMN_WIDTH_MIN; | |||
| if (requiresStackedLayout(sorted)) { | |||
| const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width)); | |||
| return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2); | |||
| } | |||
| const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2; | |||
| const branchInner = horizWidth(n, NODE_WIDTH_BRANCH); | |||
| const fullInner = horizWidth(n, NODE_WIDTH); | |||
| const contentInner = Math.max(branchInner, fullInner, innerMin); | |||
| return contentInner + CELL_PAD_X * 2; | |||
| }; | |||
| /** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */ | |||
| export const layoutCellMembers = ( | |||
| members: TraceGraphLayoutNode[], | |||
| innerCellWidth = defaultInnerCellWidth(), | |||
| ): Map<string, CellPlacement> => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const result = new Map<string, CellPlacement>(); | |||
| const n = sorted.length; | |||
| if (n === 0) return result; | |||
| const innerW = innerCellWidth; | |||
| if (n === 1 && isFlowGroupContainer(sorted[0]!)) { | |||
| const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH; | |||
| result.set(sorted[0]!.id, { | |||
| x: Math.max(0, (innerW - w) / 2), | |||
| y: CELL_PAD_Y, | |||
| compact: false, | |||
| }); | |||
| return result; | |||
| } | |||
| if (n === 1) { | |||
| result.set(sorted[0].id, { | |||
| x: (innerW - NODE_WIDTH) / 2, | |||
| y: CELL_PAD_Y, | |||
| compact: false, | |||
| }); | |||
| return result; | |||
| } | |||
| if (requiresStackedLayout(sorted)) { | |||
| return layoutStackedMembers(sorted, innerW, false); | |||
| } | |||
| let horizontal = false; | |||
| let nodeW = NODE_WIDTH_BRANCH; | |||
| if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) { | |||
| horizontal = true; | |||
| nodeW = NODE_WIDTH_BRANCH; | |||
| } else if (horizWidth(n, NODE_WIDTH) <= innerW) { | |||
| horizontal = true; | |||
| nodeW = NODE_WIDTH; | |||
| } | |||
| if (horizontal) { | |||
| const totalW = horizWidth(n, nodeW); | |||
| const startX = (innerW - totalW) / 2; | |||
| sorted.forEach((node, i) => { | |||
| result.set(node.id, { | |||
| x: startX + i * (nodeW + NODE_GAP), | |||
| y: CELL_PAD_Y, | |||
| compact: true, | |||
| }); | |||
| }); | |||
| return result; | |||
| } | |||
| return layoutStackedMembers(sorted, innerW, true); | |||
| }; | |||
| export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const memberCount = sorted.length; | |||
| if (memberCount <= 0) return LANE_MIN_HEIGHT; | |||
| if (memberCount === 1) { | |||
| const sole = sorted[0]!; | |||
| if (isFlowGroupContainer(sole) && sole.groupBoxHeight) { | |||
| return CELL_PAD_Y * 2 + sole.groupBoxHeight; | |||
| } | |||
| return CELL_PAD_Y * 2 + NODE_HEIGHT; | |||
| } | |||
| if (requiresStackedLayout(sorted)) { | |||
| return CELL_PAD_Y * 2 + stackedCellHeight(sorted); | |||
| } | |||
| const innerW = defaultInnerCellWidth(); | |||
| const fitsHoriz = | |||
| horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW || | |||
| horizWidth(memberCount, NODE_WIDTH) <= innerW; | |||
| if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT; | |||
| return CELL_PAD_Y * 2 + stackedCellHeight(sorted); | |||
| }; | |||
| export const computeLaneTops = ( | |||
| laneCount: number, | |||
| cells: Map<string, TraceGraphLayoutNode[]>, | |||
| ): { laneTops: number[]; laneHeights: number[]; totalHeight: number } => { | |||
| const laneHeights = Array.from({ length: laneCount }, () => LANE_MIN_HEIGHT); | |||
| cells.forEach((members, key) => { | |||
| const laneIndex = Number(key.split(":")[0]); | |||
| const cellH = cellContentHeight(members); | |||
| laneHeights[laneIndex] = Math.max(laneHeights[laneIndex] ?? LANE_MIN_HEIGHT, cellH); | |||
| }); | |||
| const laneTops: number[] = []; | |||
| let y = HEADER_HEIGHT; | |||
| for (let i = 0; i < laneCount; i++) { | |||
| laneTops[i] = y; | |||
| y += laneHeights[i] ?? LANE_MIN_HEIGHT; | |||
| if (i < laneCount - 1) y += LANE_GAP; | |||
| } | |||
| return { laneTops, laneHeights, totalHeight: y + 16 }; | |||
| }; | |||
| @@ -0,0 +1,223 @@ | |||
| import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; | |||
| import { TraceGraphPhase } from "./traceGraphLayout"; | |||
| /** | |||
| * Palette aligned with phase/category so kinds in different lanes do not all | |||
| * collapse onto the same chip color (outbound vs pick vs scrap vs fail). | |||
| */ | |||
| export const kindColor = ( | |||
| kind: TraceGraphNodeKind, | |||
| ): | |||
| | "success" | |||
| | "warning" | |||
| | "info" | |||
| | "secondary" | |||
| | "default" | |||
| | "primary" | |||
| | "error" => { | |||
| switch (kind) { | |||
| case "PURCHASE": | |||
| case "RECEIPT": | |||
| case "MATERIAL_IN": | |||
| case "OPEN": | |||
| case "IN": | |||
| return "success"; | |||
| case "QC": | |||
| case "MATERIAL_QC": | |||
| return "info"; | |||
| case "FAIL": | |||
| return "error"; | |||
| case "MATERIAL_PICK": | |||
| case "PICK_GROUP": | |||
| return "secondary"; | |||
| case "PRODUCTION_STEP": | |||
| case "JO_CREATED": | |||
| case "BYPRODUCT": | |||
| return "primary"; | |||
| case "SCRAP": | |||
| return "warning"; | |||
| case "DEFECT": | |||
| case "EXPIRED": | |||
| return "error"; | |||
| case "PUTAWAY": | |||
| case "TRANSFER": | |||
| case "REPACK": | |||
| return "primary"; | |||
| case "STOCK_TAKE": | |||
| return "secondary"; | |||
| case "ADJUSTMENT": | |||
| case "DEPLETED": | |||
| return "default"; | |||
| case "DO_OUT": | |||
| case "DO_GROUP": | |||
| case "REPLENISHMENT_CREATED": | |||
| case "OUT": | |||
| return "warning"; | |||
| case "JO_OUT": | |||
| case "PO_OUT": | |||
| return "secondary"; | |||
| case "RETURN": | |||
| return "error"; | |||
| default: | |||
| return "default"; | |||
| } | |||
| }; | |||
| export const kindLabelKey = (kind: TraceGraphNodeKind): string => { | |||
| switch (kind) { | |||
| case "MATERIAL_IN": | |||
| return "nodeMaterialIn"; | |||
| case "JO_CREATED": | |||
| return "nodeJoCreated"; | |||
| case "MATERIAL_QC": | |||
| return "tabQc"; | |||
| case "MATERIAL_PICK": | |||
| return "nodeMaterialPick"; | |||
| case "PRODUCTION_STEP": | |||
| return "nodeProductionStep"; | |||
| case "BYPRODUCT": | |||
| return "nodeByproduct"; | |||
| case "SCRAP": | |||
| return "nodeScrap"; | |||
| case "DEFECT": | |||
| return "nodeDefect"; | |||
| case "OPEN": | |||
| return "nodeOpen"; | |||
| case "FAIL": | |||
| return "nodeFail"; | |||
| case "DO_OUT": | |||
| return "nodeDoOut"; | |||
| case "REPLENISHMENT_CREATED": | |||
| return "nodeReplenishmentCreated"; | |||
| case "JO_OUT": | |||
| return "nodeJoOut"; | |||
| case "PO_OUT": | |||
| return "nodePoOut"; | |||
| case "DO_GROUP": | |||
| return "nodeDoGroup"; | |||
| case "PICK_GROUP": | |||
| return "nodePickGroup"; | |||
| case "RETURN": | |||
| return "nodeReturn"; | |||
| case "REPACK": | |||
| return "nodeRepack"; | |||
| case "PURCHASE": | |||
| return "nodePurchase"; | |||
| case "RECEIPT": | |||
| return "nodeReceipt"; | |||
| case "PUTAWAY": | |||
| return "nodePutaway"; | |||
| case "IN": | |||
| return "directionIn"; | |||
| case "OUT": | |||
| return "directionOut"; | |||
| case "QC": | |||
| return "tabQc"; | |||
| case "STOCK_TAKE": | |||
| return "nodeStockTake"; | |||
| case "ADJUSTMENT": | |||
| return "nodeAdjustment"; | |||
| case "TRANSFER": | |||
| return "nodeTransfer"; | |||
| case "EXPIRED": | |||
| return "nodeExpired"; | |||
| case "DEPLETED": | |||
| return "nodeDepleted"; | |||
| default: | |||
| return "type"; | |||
| } | |||
| }; | |||
| export const phaseLabelKey = (phase: TraceGraphPhase): string => { | |||
| switch (phase) { | |||
| case "MATERIAL_PICK": | |||
| return "phaseMaterialPick"; | |||
| case "PRODUCTION": | |||
| return "phaseProduction"; | |||
| case "PURCHASE": | |||
| return "phasePurchase"; | |||
| case "INBOUND": | |||
| return "phaseInbound"; | |||
| case "QC": | |||
| return "phaseQc"; | |||
| case "PUTAWAY": | |||
| return "phasePutaway"; | |||
| case "WAREHOUSE": | |||
| return "phaseWarehouse"; | |||
| case "OUTBOUND": | |||
| return "phaseOutbound"; | |||
| case "STOCK_TAKE": | |||
| return "phaseStockTake"; | |||
| default: | |||
| return "type"; | |||
| } | |||
| }; | |||
| export const phaseAccent = (phase: TraceGraphPhase): string => { | |||
| switch (phase) { | |||
| case "MATERIAL_PICK": | |||
| return "#f9a825"; | |||
| case "PRODUCTION": | |||
| return "#6a1b9a"; | |||
| case "PURCHASE": | |||
| return "#33691e"; | |||
| case "INBOUND": | |||
| return "#2e7d32"; | |||
| case "QC": | |||
| return "#0288d1"; | |||
| case "PUTAWAY": | |||
| return "#00897b"; | |||
| case "WAREHOUSE": | |||
| return "#7b1fa2"; | |||
| case "OUTBOUND": | |||
| return "#ed6c02"; | |||
| case "STOCK_TAKE": | |||
| return "#5c6bc0"; | |||
| default: | |||
| return "#757575"; | |||
| } | |||
| }; | |||
| /** MUI Chip color for a phase — matches the node chips typically shown in that lane. */ | |||
| export const phaseChipColor = ( | |||
| phase: TraceGraphPhase, | |||
| ): | |||
| | "success" | |||
| | "warning" | |||
| | "info" | |||
| | "secondary" | |||
| | "default" | |||
| | "primary" | |||
| | "error" => { | |||
| switch (phase) { | |||
| case "PURCHASE": | |||
| case "INBOUND": | |||
| return "success"; | |||
| case "QC": | |||
| return "info"; | |||
| case "PUTAWAY": | |||
| case "PRODUCTION": | |||
| case "WAREHOUSE": | |||
| return "primary"; | |||
| case "MATERIAL_PICK": | |||
| case "STOCK_TAKE": | |||
| return "secondary"; | |||
| case "OUTBOUND": | |||
| return "warning"; | |||
| default: | |||
| return "default"; | |||
| } | |||
| }; | |||
| export const minimapNodeColor = (kind: TraceGraphNodeKind): string => { | |||
| const map: Record<string, string> = { | |||
| success: "#2e7d32", | |||
| warning: "#ed6c02", | |||
| info: "#0288d1", | |||
| secondary: "#5c6bc0", | |||
| primary: "#7b1fa2", | |||
| default: "#9e9e9e", | |||
| error: "#d32f2f", | |||
| }; | |||
| return map[kindColor(kind)] ?? "#9e9e9e"; | |||
| }; | |||
| @@ -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", | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,402 @@ | |||
| import dayjs from "dayjs"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { COLUMN_WIDTH_MIN } from "./traceFlowConstants"; | |||
| import { | |||
| buildTraceGraphNodes, | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { buildJoPreludeGraphNodes, JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes"; | |||
| import { | |||
| buildProductionGraphNodes, | |||
| ProductionGraphLabels, | |||
| } from "./buildProductionGraphNodes"; | |||
| import { | |||
| buildExtendedTraceGraphNodes, | |||
| ExtendedTraceGraphLabels, | |||
| } from "./buildExtendedTraceGraphNodes"; | |||
| import { buildAllLocationBlockGraphNodes } from "./buildLocationBlockGraphNodes"; | |||
| import { applyFlowNodeGrouping, isDoGroupChild, regroupLayoutCells } from "./traceDoGroupLayout"; | |||
| import { cellWidthRequirement } from "./traceFlowLayout"; | |||
| import { | |||
| materialInputsHaveProduction, | |||
| phaseFromKind, | |||
| sortNodesInPhase, | |||
| sortPhasesByEarliestEvent, | |||
| } from "./traceGraphSemantics"; | |||
| export { | |||
| buildTraceFlowEdgePairs, | |||
| materialInputsHaveProduction, | |||
| phaseFromKind, | |||
| sortNodesInPhase, | |||
| sortPhasesByEarliestEvent, | |||
| } from "./traceGraphSemantics"; | |||
| export type PreProductionPhase = "MATERIAL_PICK"; | |||
| export type ProductionPhase = "PRODUCTION"; | |||
| export type FgTraceGraphPhase = | |||
| | "PURCHASE" | |||
| | "INBOUND" | |||
| | "QC" | |||
| | "PUTAWAY" | |||
| | "WAREHOUSE" | |||
| | "OUTBOUND" | |||
| | "STOCK_TAKE"; | |||
| /** @deprecated MATERIAL_QC merged into QC lane when prelude is shown */ | |||
| export type LegacyMaterialQcPhase = "MATERIAL_QC"; | |||
| export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase; | |||
| export const PRE_PRODUCTION_PHASES: PreProductionPhase[] = ["MATERIAL_PICK"]; | |||
| export const FG_PHASE_ORDER: FgTraceGraphPhase[] = [ | |||
| "PURCHASE", | |||
| "INBOUND", | |||
| "QC", | |||
| "PUTAWAY", | |||
| "WAREHOUSE", | |||
| "STOCK_TAKE", | |||
| "OUTBOUND", | |||
| ]; | |||
| /** Prelude rows use one shared QC lane (原料 + 成品品檢). */ | |||
| export const PRELUDE_PHASE_ORDER = ( | |||
| hasProduction: boolean, | |||
| ): TraceGraphPhase[] => { | |||
| const phases: TraceGraphPhase[] = [ | |||
| "PURCHASE", | |||
| "INBOUND", | |||
| "QC", | |||
| "PUTAWAY", | |||
| "MATERIAL_PICK", | |||
| ]; | |||
| if (hasProduction) phases.push("PRODUCTION"); | |||
| phases.push("WAREHOUSE", "STOCK_TAKE", "OUTBOUND"); | |||
| return phases; | |||
| }; | |||
| export const getPhaseOrder = (hasPrelude: boolean, hasProduction: boolean): TraceGraphPhase[] => { | |||
| if (hasPrelude) return PRELUDE_PHASE_ORDER(hasProduction); | |||
| const phases: TraceGraphPhase[] = [...FG_PHASE_ORDER]; | |||
| if (hasProduction) phases.splice(1, 0, "PRODUCTION"); | |||
| return phases; | |||
| }; | |||
| /** @deprecated use layout.phaseOrder */ | |||
| export const PHASE_ORDER: TraceGraphPhase[] = FG_PHASE_ORDER; | |||
| /** @deprecated use layout.laneCount */ | |||
| export const LANE_COUNT = FG_PHASE_ORDER.length; | |||
| export interface TraceGraphLayoutNode extends TraceGraphNode { | |||
| phase: TraceGraphPhase; | |||
| column: number; | |||
| dayKey: string; | |||
| laneIndex: number; | |||
| sequenceIndex: number; | |||
| branchIndex: number; | |||
| branchSize: number; | |||
| /** Slot index within the day column (one dynamic phase slot per phase on that day). */ | |||
| dayPhaseStaggerIndex: number; | |||
| } | |||
| export interface DayColumnLayout { | |||
| columnStarts: number[]; | |||
| columnWidths: number[]; | |||
| /** Width of each phase slot within a calendar day (same order as phases on that day). */ | |||
| phaseSlotWidths: number[][]; | |||
| /** Start X offset of each phase slot within a calendar day. */ | |||
| phaseSlotStarts: number[][]; | |||
| timelineWidth: number; | |||
| } | |||
| export interface TraceGraphLayout { | |||
| nodes: TraceGraphLayoutNode[]; | |||
| columnCount: number; | |||
| columnDates: string[]; | |||
| dayColumns: DayColumnLayout; | |||
| cellGroups: TraceGraphLayoutNode[][]; | |||
| phaseOrder: TraceGraphPhase[]; | |||
| laneCount: number; | |||
| hasPrelude: boolean; | |||
| hasProduction: boolean; | |||
| } | |||
| export const laneIndexFromPhase = (phase: TraceGraphPhase, phaseOrder: TraceGraphPhase[]): number => | |||
| phaseOrder.indexOf(phase); | |||
| const cellKey = (column: number, laneIndex: number) => `${column}:${laneIndex}`; | |||
| export const dayKeyFromTimestamp = (timestamp: string | null): string => { | |||
| if (!timestamp?.trim()) return "—"; | |||
| const d = dayjs(timestamp); | |||
| if (!d.isValid()) return timestamp.slice(0, 10) || "—"; | |||
| return d.format("YYYY-MM-DD"); | |||
| }; | |||
| export const phasesOnDay = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| dayKey: string, | |||
| phaseOrder: TraceGraphPhase[], | |||
| ): TraceGraphPhase[] => { | |||
| const present = new Set<TraceGraphPhase>(); | |||
| nodes.forEach((n) => { | |||
| if (n.dayKey === dayKey) present.add(n.phase); | |||
| }); | |||
| return sortPhasesByEarliestEvent(nodes, Array.from(present), phaseOrder, dayKey); | |||
| }; | |||
| /** Widen each calendar day by chronological time slots (not phase-packed slots). */ | |||
| export const computeDayColumnLayout = ( | |||
| columnDates: string[], | |||
| nodes: TraceGraphLayoutNode[], | |||
| _phaseOrder: TraceGraphPhase[], | |||
| ): DayColumnLayout => { | |||
| const visible = nodes.filter((n) => !isDoGroupChild(n)); | |||
| const phaseSlotWidths: number[][] = []; | |||
| const phaseSlotStarts: number[][] = []; | |||
| const columnWidths: number[] = []; | |||
| columnDates.forEach((dayKey, colIndex) => { | |||
| const dayNodes = visible.filter((n) => n.column === colIndex && n.dayKey === dayKey); | |||
| const maxSlot = dayNodes.reduce((m, n) => Math.max(m, n.dayPhaseStaggerIndex), 0); | |||
| const slotCount = dayNodes.length === 0 ? 1 : maxSlot + 1; | |||
| const slotWidths: number[] = []; | |||
| for (let slot = 0; slot < slotCount; slot++) { | |||
| const members = dayNodes.filter((n) => n.dayPhaseStaggerIndex === slot); | |||
| slotWidths.push(cellWidthRequirement(members)); | |||
| } | |||
| const starts: number[] = []; | |||
| let dayWidth = 0; | |||
| slotWidths.forEach((w) => { | |||
| starts.push(dayWidth); | |||
| dayWidth += w; | |||
| }); | |||
| phaseSlotWidths.push(slotWidths); | |||
| phaseSlotStarts.push(starts); | |||
| columnWidths.push(Math.max(COLUMN_WIDTH_MIN, dayWidth)); | |||
| }); | |||
| const columnStarts: number[] = []; | |||
| let x = 0; | |||
| for (const w of columnWidths) { | |||
| columnStarts.push(x); | |||
| x += w; | |||
| } | |||
| return { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts, timelineWidth: x }; | |||
| }; | |||
| /** | |||
| * Within each calendar day, assign left→right slots by event time (sortKey). | |||
| * Avoids packing an entire phase to the left of another phase when later events | |||
| * in the early phase happen after earlier events in a later phase. | |||
| */ | |||
| export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => { | |||
| const byDay = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((n) => { | |||
| if (isDoGroupChild(n)) return; | |||
| const list = byDay.get(n.dayKey) ?? []; | |||
| list.push(n); | |||
| byDay.set(n.dayKey, list); | |||
| }); | |||
| byDay.forEach((dayNodes) => { | |||
| dayNodes.sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| // Same timestamp: putaway before material ADJUSTMENT only (上架 → 庫存調整). | |||
| if ( | |||
| (a.kind === "PUTAWAY" || a.kind === "ADJUSTMENT") && | |||
| (b.kind === "PUTAWAY" || b.kind === "ADJUSTMENT") && | |||
| a.kind !== b.kind | |||
| ) { | |||
| return a.kind === "PUTAWAY" ? -1 : 1; | |||
| } | |||
| // 建立工單 before 工單提料 group / lines in the same pick phase. | |||
| const pickLaneOrder = (n: TraceGraphLayoutNode) => | |||
| n.kind === "JO_CREATED" ? 0 : n.kind === "PICK_GROUP" ? 1 : n.kind === "MATERIAL_PICK" ? 2 : 3; | |||
| const pickA = pickLaneOrder(a); | |||
| const pickB = pickLaneOrder(b); | |||
| if (pickA !== pickB) return pickA - pickB; | |||
| // Then canonical phase / lane order (e.g. QC before putaway). | |||
| if (a.laneIndex !== b.laneIndex) return a.laneIndex - b.laneIndex; | |||
| if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| dayNodes.forEach((n, i) => { | |||
| n.dayPhaseStaggerIndex = i; | |||
| n.branchIndex = 0; | |||
| n.branchSize = 1; | |||
| }); | |||
| }); | |||
| // Group children share the container's horizontal time slot. | |||
| const parentById = new Map(nodes.map((n) => [n.id, n])); | |||
| nodes.forEach((n) => { | |||
| if (!n.doGroupId) return; | |||
| const parent = parentById.get(n.doGroupId); | |||
| if (parent) n.dayPhaseStaggerIndex = parent.dayPhaseStaggerIndex; | |||
| }); | |||
| }; | |||
| const assignColumns = ( | |||
| sorted: TraceGraphNode[], | |||
| phaseOrder: TraceGraphPhase[], | |||
| ): { columnDates: string[]; nodes: TraceGraphLayoutNode[]; cellGroups: TraceGraphLayoutNode[][] } => { | |||
| const dayOrder: string[] = []; | |||
| const columnByDayKey = new Map<string, number>(); | |||
| const withMeta: TraceGraphLayoutNode[] = sorted.map((node, sequenceIndex) => { | |||
| const dayKey = dayKeyFromTimestamp(node.timestamp); | |||
| let column = columnByDayKey.get(dayKey); | |||
| if (column === undefined) { | |||
| column = dayOrder.length; | |||
| dayOrder.push(dayKey); | |||
| columnByDayKey.set(dayKey, column); | |||
| } | |||
| const phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo, node.refType); | |||
| return { | |||
| ...node, | |||
| phase, | |||
| dayKey, | |||
| column, | |||
| laneIndex: laneIndexFromPhase(phase, phaseOrder), | |||
| sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: 0, | |||
| }; | |||
| }); | |||
| // Provisional phase-based stagger (overwritten after grouping by chronological slots). | |||
| const staggerByDay = new Map<string, TraceGraphPhase[]>(); | |||
| withMeta.forEach((node) => { | |||
| if (!staggerByDay.has(node.dayKey)) { | |||
| staggerByDay.set(node.dayKey, phasesOnDay(withMeta, node.dayKey, phaseOrder)); | |||
| } | |||
| const ordered = staggerByDay.get(node.dayKey)!; | |||
| node.dayPhaseStaggerIndex = Math.max(0, ordered.indexOf(node.phase)); | |||
| }); | |||
| const groups = new Map<string, TraceGraphLayoutNode[]>(); | |||
| withMeta.forEach((node) => { | |||
| const key = cellKey(node.column, node.laneIndex); | |||
| const list = groups.get(key) ?? []; | |||
| list.push(node); | |||
| groups.set(key, list); | |||
| }); | |||
| groups.forEach((list) => { | |||
| list.sort(sortNodesInPhase); | |||
| const size = list.length; | |||
| list.forEach((node, idx) => { | |||
| node.branchIndex = idx; | |||
| node.branchSize = size; | |||
| }); | |||
| }); | |||
| const cellGroups = Array.from(groups.values()).sort((a, b) => { | |||
| const na = a[0]; | |||
| const nb = b[0]; | |||
| if (na.column !== nb.column) return na.column - nb.column; | |||
| return na.laneIndex - nb.laneIndex; | |||
| }); | |||
| return { columnDates: dayOrder, nodes: withMeta, cellGroups }; | |||
| }; | |||
| export const buildTraceGraphLayout = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: TraceGraphDetailLabels & | |||
| Partial<JoPreludeGraphLabels> & | |||
| Partial<ProductionGraphLabels> & | |||
| Partial<ExtendedTraceGraphLabels> & { | |||
| flowDoGroupTitle?: (count: number) => string; | |||
| flowPickGroupTitle?: (pickOrderCode: string, count: number) => string; | |||
| }, | |||
| ): TraceGraphLayout => { | |||
| const hasPrelude = data.joPrelude != null; | |||
| const hasScrap = (data.productionSteps ?? []).some( | |||
| (s) => (s.scrapQty ?? 0) + (s.defectQty ?? 0) > 0, | |||
| ); | |||
| const hasMaterialProduction = | |||
| data.joPrelude != null && materialInputsHaveProduction(data.joPrelude.materialInputs); | |||
| const hasProduction = | |||
| (data.productionSteps?.length ?? 0) > 0 || | |||
| (data.byproductLots?.length ?? 0) > 0 || | |||
| hasScrap || | |||
| hasMaterialProduction; | |||
| const phaseOrder = getPhaseOrder(hasPrelude, hasProduction); | |||
| const mergedMultiLocation = (data.locationBlocks?.length ?? 0) > 0; | |||
| const primaryWh = | |||
| data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined; | |||
| const primaryScope = { | |||
| defaultWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| mergedMultiLocation, | |||
| }; | |||
| const fgNodes = buildTraceGraphNodes(data, labels, primaryScope); | |||
| const preludeNodes = | |||
| data.joPrelude && labels.nodeMaterialIn && labels.nodeMaterialPick && labels.nodePurchase | |||
| ? buildJoPreludeGraphNodes(data.joPrelude, labels as JoPreludeGraphLabels) | |||
| : []; | |||
| const productionNodes = | |||
| hasProduction && labels.nodeProductionStep && labels.nodeScrap && labels.nodeDefect | |||
| ? buildProductionGraphNodes( | |||
| data.productionSteps ?? [], | |||
| data.byproductLots ?? [], | |||
| labels as ProductionGraphLabels, | |||
| data.lot.uom, | |||
| ) | |||
| : []; | |||
| const extendedNodes = | |||
| labels.nodeOpen && labels.nodeFail && labels.nodeDoOut | |||
| ? buildExtendedTraceGraphNodes(data, labels as ExtendedTraceGraphLabels, primaryScope) | |||
| : []; | |||
| const locationNodes = | |||
| (data.locationBlocks?.length ?? 0) > 0 && | |||
| labels.nodeOpen && | |||
| labels.nodeFail && | |||
| labels.nodeDoOut | |||
| ? buildAllLocationBlockGraphNodes(data.locationBlocks ?? [], labels as ExtendedTraceGraphLabels) | |||
| : []; | |||
| const merged = [...preludeNodes, ...productionNodes, ...extendedNodes, ...fgNodes, ...locationNodes].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| const { columnDates, nodes: assigned, cellGroups: _initialCellGroups } = assignColumns( | |||
| merged, | |||
| phaseOrder, | |||
| ); | |||
| const groupTitleDo = | |||
| labels.flowDoGroupTitle ?? ((count: number) => `DO × ${count}`); | |||
| const groupTitlePick = | |||
| labels.flowPickGroupTitle ?? | |||
| ((pickOrderCode: string, count: number) => `${pickOrderCode} × ${count}`); | |||
| const nodes = applyFlowNodeGrouping(assigned, { | |||
| flowDoGroupTitle: groupTitleDo, | |||
| flowPickGroupTitle: groupTitlePick, | |||
| }); | |||
| assignChronologicalDaySlots(nodes); | |||
| const cellGroups = regroupLayoutCells(nodes); | |||
| const columnCount = Math.max(columnDates.length, 1); | |||
| const dayColumns = computeDayColumnLayout(columnDates, nodes, phaseOrder); | |||
| return { | |||
| nodes, | |||
| columnCount, | |||
| columnDates, | |||
| dayColumns, | |||
| cellGroups, | |||
| phaseOrder, | |||
| laneCount: phaseOrder.length, | |||
| hasPrelude, | |||
| hasProduction, | |||
| }; | |||
| }; | |||
| @@ -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); | |||
| }; | |||
| @@ -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<string, ItemLotTraceQcResult[]>(); | |||
| results.forEach((q) => { | |||
| const key = `${q.stockInLineId}:${q.created ?? ""}`; | |||
| const list = map.get(key) ?? []; | |||
| list.push(q); | |||
| map.set(key, list); | |||
| }); | |||
| return Array.from(map.values()); | |||
| }; | |||
| export const buildQcCriteriaLines = ( | |||
| results: ItemLotTraceQcResult[], | |||
| tr: TraceLabelTranslator, | |||
| unknownItemLabel: string, | |||
| failQtyLabel: string, | |||
| ): string => { | |||
| if (results.length === 0) return "—"; | |||
| return results | |||
| .map((q) => { | |||
| const item = qcItemLabel(q) || unknownItemLabel; | |||
| const status = q.qcPassed | |||
| ? tr.qcPassed(true) | |||
| : `${tr.qcPassed(false)}${q.failQty > 0 ? `(${failQtyLabel}: ${q.failQty})` : ""}`; | |||
| return `${item}:${status}`; | |||
| }) | |||
| .join("\n"); | |||
| }; | |||
| export const buildQcSubtitle = ( | |||
| results: ItemLotTraceQcResult[], | |||
| formatQcSubtitle: (failQty: number, acceptedQty: number) => string, | |||
| extra?: string, | |||
| ): string => { | |||
| const first = results[0]; | |||
| const qtyLine = first ? formatQcSubtitle(first.failQty, first.acceptedQty) : ""; | |||
| const criteria = results | |||
| .map((q) => qcItemShortLabel(q)) | |||
| .filter(Boolean) | |||
| .slice(0, 3) | |||
| .join(" · "); | |||
| const parts = [extra, criteria, qtyLine].filter(Boolean); | |||
| return parts.join(" · ") || qtyLine || "—"; | |||
| }; | |||
| export type DoOutboundKindLabels = { | |||
| extra: string; | |||
| replenish: string; | |||
| }; | |||
| /** Detail label for 加單·補貨. */ | |||
| export const formatDoOutboundKindLabel = ( | |||
| isExtra?: boolean, | |||
| isReplenish?: boolean, | |||
| labels?: DoOutboundKindLabels, | |||
| ): string | undefined => { | |||
| if (!labels) return undefined; | |||
| const parts: string[] = []; | |||
| if (isExtra) parts.push(labels.extra); | |||
| if (isReplenish) parts.push(labels.replenish); | |||
| return parts.length > 0 ? parts.join(" · ") : undefined; | |||
| }; | |||
| import { | |||
| resolveTraceDoOutboundIsExtra, | |||
| type TraceDoOutboundExtraSource, | |||
| } from "@/utils/traceDoOutboundExtra"; | |||
| export type DoOutboundChipSource = TraceDoOutboundExtraSource & { | |||
| isReplenish?: boolean; | |||
| }; | |||
| /** Resolve chip flags from API fields, with ticket-type fallback (TI-E / TI-M rules). */ | |||
| export const resolveDoOutboundChipFlags = ( | |||
| source: DoOutboundChipSource, | |||
| ): { isExtra: boolean; isReplenish: boolean } => ({ | |||
| isExtra: resolveTraceDoOutboundIsExtra(source), | |||
| isReplenish: source.isReplenish === true, | |||
| }); | |||
| @@ -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"; | |||
| }; | |||
| @@ -0,0 +1,213 @@ | |||
| import dayjs from "dayjs"; | |||
| import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDocLinkKind, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import { pickStatusValueColor } from "./traceLabelUtils"; | |||
| import { normalizeTargetDateForLink } from "./traceDocLinkUtils"; | |||
| export type MaterialPickLabels = { | |||
| nodeMaterialPick: string; | |||
| categoryPick: string; | |||
| pickOrder: string; | |||
| jobOrder: string; | |||
| detailMaterial: string; | |||
| detailLot: string; | |||
| detailQty: string; | |||
| detailType: string; | |||
| detailAssignedStep: string; | |||
| detailPickTargetDate: string; | |||
| detailTime: string; | |||
| processingStatus: string; | |||
| matchStatus: string; | |||
| tr: { | |||
| processingStatus: (code: string | null | undefined) => string; | |||
| matchStatus: (code: string | null | undefined) => string; | |||
| }; | |||
| }; | |||
| export const pickStatusDetailFields = ( | |||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| ): TraceGraphDetailField[] => { | |||
| const always = opts?.always === true; | |||
| const rows: TraceGraphDetailField[] = []; | |||
| if (always || processingStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.processingStatus, labels.tr.processingStatus(processingStatus), { | |||
| valueColor: pickStatusValueColor(processingStatus), | |||
| }), | |||
| ); | |||
| } | |||
| if (always || matchStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.matchStatus, labels.tr.matchStatus(matchStatus), { | |||
| valueColor: pickStatusValueColor(matchStatus), | |||
| }), | |||
| ); | |||
| } | |||
| return rows; | |||
| }; | |||
| export const pickStatusNodeLabels = ( | |||
| labels: Pick<MaterialPickLabels, "tr">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| ): Pick< | |||
| TraceGraphNode, | |||
| "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" | |||
| > => { | |||
| const always = opts?.always === true; | |||
| const showProcessing = always || Boolean(processingStatus?.trim()); | |||
| const showMatch = always || Boolean(matchStatus?.trim()); | |||
| return { | |||
| processingStatusLabel: showProcessing | |||
| ? labels.tr.processingStatus(processingStatus) | |||
| : undefined, | |||
| matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, | |||
| processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, | |||
| matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, | |||
| }; | |||
| }; | |||
| export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—"); | |||
| export const field = ( | |||
| label: string, | |||
| value: string | null | undefined, | |||
| extra?: Partial<TraceGraphDetailField>, | |||
| ): TraceGraphDetailField => ({ | |||
| label, | |||
| value: fmt(value), | |||
| ...extra, | |||
| }); | |||
| /** Omit detail row when value is blank (used for optional remarks, etc.). */ | |||
| export const fieldIf = ( | |||
| label: string, | |||
| value: string | number | null | undefined, | |||
| extra?: Partial<TraceGraphDetailField>, | |||
| ): TraceGraphDetailField | null => { | |||
| const raw = value == null ? "" : String(value).trim(); | |||
| if (!raw) return null; | |||
| return field(label, raw, extra); | |||
| }; | |||
| export const detailsOf = ( | |||
| ...rows: Array<TraceGraphDetailField | null | undefined | false> | |||
| ): TraceGraphDetailField[] => rows.filter((row): row is TraceGraphDetailField => Boolean(row)); | |||
| export const parseSortKey = (ts: string | null | undefined, seq: number): number => { | |||
| if (!ts?.trim()) return seq; | |||
| const d = dayjs(ts); | |||
| return d.isValid() ? d.valueOf() : seq; | |||
| }; | |||
| /** Expiry is date-only in master data; sort at end of that calendar day (23:59:59). */ | |||
| export const parseExpirySortKey = (expiry: string | null | undefined, seq: number): number => { | |||
| if (!expiry?.trim()) return seq; | |||
| const d = dayjs(expiry.trim().slice(0, 10)); | |||
| return d.isValid() ? d.endOf("day").valueOf() : seq; | |||
| }; | |||
| export const docLinkFromOriginType = (type: string): TraceGraphDocLinkKind | undefined => { | |||
| const t = type.toUpperCase(); | |||
| if (t === "PO") return "po"; | |||
| if (t === "JO") return "jo"; | |||
| return undefined; | |||
| }; | |||
| export const docLinkFromRefType = (refType: string): TraceGraphDocLinkKind | undefined => { | |||
| const t = refType.toUpperCase(); | |||
| if (t === "PO") return "po"; | |||
| if (t === "JO") return "jo"; | |||
| if (t === "PICK" || t === "DO") return "pick"; | |||
| if (t === "JO_PICK") return "jodetail"; | |||
| return undefined; | |||
| }; | |||
| /** Semi-finished / nested JO output used as a material for another JO. */ | |||
| export const isJoProducedMaterial = (m: ItemLotTraceMaterialInput): boolean => | |||
| (m.productionSteps?.length ?? 0) > 0 || | |||
| Boolean(m.nestedJoPrelude) || | |||
| m.stockInOrigin?.type?.trim().toUpperCase() === "JO"; | |||
| export type MaterialPickNodeContext = { | |||
| nextSeq: () => number; | |||
| pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; | |||
| }; | |||
| export const createMaterialPickNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| index: number, | |||
| labels: MaterialPickLabels, | |||
| ctx: MaterialPickNodeContext, | |||
| feedsProductionScopeLotNo?: string, | |||
| ): TraceGraphNode => { | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; | |||
| const stepLabel = m.assignedStepName?.trim() | |||
| ? m.bomProcessSeqNo != null | |||
| ? `${m.bomProcessSeqNo}. ${m.assignedStepName}` | |||
| : m.assignedStepName | |||
| : ""; | |||
| const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · "); | |||
| const pickTargetDate = normalizeTargetDateForLink( | |||
| ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), | |||
| ); | |||
| const pickedDate = normalizeTargetDateForLink(m.pickedAt); | |||
| const linkTargetDate = pickTargetDate || pickedDate; | |||
| return { | |||
| id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, | |||
| kind: "MATERIAL_PICK", | |||
| timestamp: m.pickedAt, | |||
| sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), | |||
| title: pickTitle, | |||
| subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "), | |||
| qty: m.materialQty, | |||
| uom: matUom, | |||
| meta: itemLabel || undefined, | |||
| refCode: m.pickOrderCode, | |||
| refId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| jobOrderCode: m.jobOrderCode?.trim() || undefined, | |||
| docLinkKind: m.pickOrderCode ? "jodetail" : undefined, | |||
| docLinkTargetDate: linkTargetDate, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| bomProcessId: m.bomProcessId, | |||
| bomProcessSeqNo: m.bomProcessSeqNo, | |||
| assignedStepName: m.assignedStepName, | |||
| feedsProductionScopeLotNo, | |||
| categoryLabel: labels.categoryPick, | |||
| details: [ | |||
| field(labels.pickOrder, m.pickOrderCode, { | |||
| linkKind: "jodetail", | |||
| linkCode: m.pickOrderCode, | |||
| linkId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| linkTargetDate, | |||
| }), | |||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate), | |||
| field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(m.materialQty, matUom)), | |||
| field(labels.detailType, labels.nodeMaterialPick), | |||
| field(labels.detailAssignedStep, stepLabel || "—"), | |||
| field(labels.jobOrder, m.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: m.jobOrderCode, | |||
| linkId: m.jobOrderId, | |||
| }), | |||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus), | |||
| field(labels.detailTime, m.pickedAt), | |||
| ], | |||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus), | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,200 @@ | |||
| import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import type { TraceGraphLayoutNode } from "./traceGraphLayout"; | |||
| import type { TraceGraphNodeKind } from "./buildTraceGraphNodes"; | |||
| import { mergeScopedMovements } from "./mergeLocationScopedData"; | |||
| export type TraceMovementRow = { | |||
| id: string; | |||
| direction: string; | |||
| qty: number; | |||
| movementType: string; | |||
| refType: string; | |||
| refCode: string; | |||
| refId: number | null; | |||
| warehouseCode: string; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| remarks: string; | |||
| }; | |||
| export type TraceJoPickRow = { | |||
| id: string; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| materialItemCode: string; | |||
| materialLotNo: string; | |||
| qty: number; | |||
| uom: string; | |||
| pickedAt: string | null; | |||
| bomProcessId: number | null; | |||
| assignedStepName: string; | |||
| /** YYYY-MM-DD for jodetail deep links. */ | |||
| targetDate?: string; | |||
| }; | |||
| export type TraceProductionRow = { | |||
| id: string; | |||
| stepName: string; | |||
| description: string; | |||
| equipmentCode: string; | |||
| equipmentName: string; | |||
| operatorName: string; | |||
| startTime: string | null; | |||
| endTime: string | null; | |||
| outputQty: number; | |||
| scrapQty: number; | |||
| defectQty: number; | |||
| status: string; | |||
| seqNo: number | null; | |||
| bomProcessId: number | null; | |||
| traceLotNo: string | null; | |||
| }; | |||
| const detailValue = (node: TraceGraphLayoutNode, labelIncludes: string): string => { | |||
| const row = node.details.find((d) => d.label.includes(labelIncludes)); | |||
| return row?.value?.trim() || "—"; | |||
| }; | |||
| const movementKinds: TraceGraphNodeKind[] = [ | |||
| "IN", | |||
| "OUT", | |||
| "RECEIPT", | |||
| "OPEN", | |||
| "RETURN", | |||
| "REPACK", | |||
| ]; | |||
| export const deriveMovementRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| data: ItemLotTraceResponse, | |||
| ): TraceMovementRow[] => { | |||
| const fromGraph = nodes | |||
| .filter((n) => movementKinds.includes(n.kind)) | |||
| .map((n) => ({ | |||
| id: n.id, | |||
| direction: n.kind === "OUT" || n.kind === "RETURN" ? "OUT" : "IN", | |||
| qty: n.qty ?? 0, | |||
| movementType: n.subtitle?.trim() || n.kind, | |||
| refType: n.refType?.trim() || "", | |||
| refCode: n.refCode?.trim() || "", | |||
| refId: n.refId ?? null, | |||
| warehouseCode: n.warehouseCode?.trim() || (detailValue(n, "Warehouse") !== "—" ? detailValue(n, "Warehouse") : n.meta?.split(" · ")[1] ?? ""), | |||
| handledBy: detailValue(n, "Handler") !== "—" ? detailValue(n, "Handler") : n.meta?.split(" · ")[0] ?? "", | |||
| timestamp: n.timestamp, | |||
| remarks: detailValue(n, "Remarks"), | |||
| })); | |||
| if (fromGraph.length > 0) return fromGraph; | |||
| return mergeScopedMovements(data).map((m) => ({ | |||
| id: m.rowKey, | |||
| direction: m.direction, | |||
| qty: m.qty, | |||
| movementType: m.movementType, | |||
| refType: m.refType, | |||
| refCode: m.refCode, | |||
| refId: m.refId, | |||
| warehouseCode: m.scopeWarehouseCode, | |||
| handledBy: m.handledBy, | |||
| timestamp: m.timestamp, | |||
| remarks: m.remarks, | |||
| })); | |||
| }; | |||
| export const deriveJoPickRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| fallbackInputs: NonNullable<ItemLotTraceResponse["joPrelude"]>["materialInputs"] | undefined, | |||
| ): TraceJoPickRow[] => { | |||
| const picks = nodes.filter((n) => n.kind === "MATERIAL_PICK"); | |||
| if (picks.length > 0) { | |||
| return picks.map((n) => ({ | |||
| id: n.id, | |||
| pickOrderCode: n.refCode?.trim() || n.title?.trim() || "", | |||
| pickOrderId: n.refId ?? null, | |||
| consoCode: n.consoCode?.trim() || "", | |||
| materialItemCode: n.traceItemCode?.trim() || "", | |||
| materialLotNo: n.traceLotNo?.trim() || "", | |||
| qty: n.qty ?? 0, | |||
| uom: n.uom?.trim() || "", | |||
| pickedAt: n.timestamp, | |||
| bomProcessId: n.bomProcessId ?? null, | |||
| assignedStepName: n.assignedStepName?.trim() || "", | |||
| targetDate: n.docLinkTargetDate?.trim() || undefined, | |||
| })); | |||
| } | |||
| return (fallbackInputs ?? []).map((m, i) => ({ | |||
| id: `pick-api-${i}-${m.pickOrderCode}-${m.materialLotNo}`, | |||
| pickOrderCode: m.pickOrderCode, | |||
| pickOrderId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| materialItemCode: m.materialItemCode, | |||
| materialLotNo: m.materialLotNo, | |||
| qty: m.materialQty, | |||
| uom: m.materialUom ?? "", | |||
| pickedAt: m.pickedAt, | |||
| bomProcessId: m.bomProcessId ?? null, | |||
| assignedStepName: m.assignedStepName ?? "", | |||
| targetDate: undefined, | |||
| })); | |||
| }; | |||
| export const deriveProductionRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| fallback: ItemLotTraceResponse["productionSteps"], | |||
| ): TraceProductionRow[] => { | |||
| const steps = nodes.filter((n) => n.kind === "PRODUCTION_STEP"); | |||
| if (steps.length > 0) { | |||
| return steps.map((n) => ({ | |||
| id: n.id, | |||
| stepName: n.title?.trim() || detailValue(n, "Step"), | |||
| description: detailValue(n, "Remarks"), | |||
| equipmentCode: "", | |||
| equipmentName: detailValue(n, "Equipment"), | |||
| operatorName: detailValue(n, "Handler"), | |||
| startTime: null, | |||
| endTime: n.timestamp, | |||
| outputQty: n.qty ?? 0, | |||
| scrapQty: 0, | |||
| defectQty: 0, | |||
| status: detailValue(n, "Status"), | |||
| seqNo: n.bomProcessSeqNo ?? null, | |||
| bomProcessId: n.bomProcessId ?? null, | |||
| traceLotNo: n.traceLotNo ?? null, | |||
| })); | |||
| } | |||
| return fallback.map((s) => ({ | |||
| id: `prod-api-${s.processLineId}`, | |||
| stepName: s.stepName, | |||
| description: s.description, | |||
| equipmentCode: s.equipmentCode, | |||
| equipmentName: s.equipmentName, | |||
| operatorName: s.operatorName, | |||
| startTime: s.startTime, | |||
| endTime: s.endTime, | |||
| outputQty: s.outputQty, | |||
| scrapQty: s.scrapQty, | |||
| defectQty: s.defectQty, | |||
| status: s.status, | |||
| seqNo: s.seqNo, | |||
| bomProcessId: s.bomProcessId ?? null, | |||
| traceLotNo: null, | |||
| })); | |||
| }; | |||
| export type TracePresentationRows = { | |||
| movements: TraceMovementRow[]; | |||
| joPicks: TraceJoPickRow[]; | |||
| production: TraceProductionRow[]; | |||
| }; | |||
| export const buildTracePresentationRows = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| data: ItemLotTraceResponse, | |||
| ): TracePresentationRows => ({ | |||
| movements: deriveMovementRowsFromGraph(nodes, data), | |||
| joPicks: deriveJoPickRowsFromGraph(nodes, data.joPrelude?.materialInputs), | |||
| production: deriveProductionRowsFromGraph(nodes, data.productionSteps), | |||
| }); | |||
| @@ -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, | |||
| }; | |||
| }; | |||
| @@ -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; | |||
| }; | |||
| @@ -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; | |||
| }; | |||
| @@ -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<Props> = ({ printerCombo }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const searchParams = useSearchParams(); | |||
| const urlTabRaw = searchParams.get("tab"); | |||
| const urlPickOrderCode = searchParams.get("pickOrderCode")?.trim() || undefined; | |||
| const urlTargetDateRaw = searchParams.get("targetDate")?.trim() || undefined; | |||
| const urlTargetDate = (() => { | |||
| if (!urlTargetDateRaw) return undefined; | |||
| const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| return match ? match[1] : undefined; | |||
| })(); | |||
| 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<Props> = ({ printerCombo }) => { | |||
| //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | |||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0); | |||
| const [totalCount, setTotalCount] = useState<number>(); | |||
| const [isAssigning, setIsAssigning] = useState(false); | |||
| const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | |||
| @@ -70,6 +85,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| const [hasDataTab0, setHasDataTab0] = useState(false); | |||
| const [hasDataTab1, setHasDataTab1] = useState(false); | |||
| const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | |||
| useEffect(() => { | |||
| if (urlTabIndex != null) setTabIndex(urlTabIndex); | |||
| }, [urlTabIndex]); | |||
| // Add printer selection state | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| @@ -492,6 +511,8 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| printerCombo={printerCombo} | |||
| selectedPrinter={selectedPrinter} | |||
| printQty={printQty} | |||
| initialPickOrderCode={urlPickOrderCode} | |||
| initialTargetDate={urlTargetDate} | |||
| /> | |||
| )} | |||
| {tabIndex === 2 && <MaterialPickStatusTable />} | |||
| @@ -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<Props> = ({ | |||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| const [detailLotDataLoading, setDetailLotDataLoading] = useState(false); | |||
| // 修改:搜索状态 | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => ({ | |||
| completedDate: dayjs().format("YYYY-MM-DD"), | |||
| })); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => { | |||
| const raw = initialTargetDate?.trim() || ""; | |||
| const match = raw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| return { | |||
| completedDate: match?.[1] || dayjs().format("YYYY-MM-DD"), | |||
| ...(initialPickOrderCode?.trim() | |||
| ? { pickOrderCode: initialPickOrderCode.trim() } | |||
| : {}), | |||
| }; | |||
| }); | |||
| const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]); | |||
| // Use props with fallback | |||
| const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | |||
| const printQty = printQtyProp ?? 1; | |||
| const pickRecordPrintInFlightRef = useRef(false); | |||
| const initialDetailOpenedRef = useRef(false); | |||
| // 修改:分页状态 | |||
| const [paginationController, setPaginationController] = useState({ | |||
| @@ -370,6 +382,25 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| }, [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); | |||
| @@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => { | |||
| icon: <Storefront />, | |||
| labelKey: "nav.storeManagement", | |||
| path: "", | |||
| requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ITEM_TRACING, AUTH.ADMIN], | |||
| children: [ | |||
| { | |||
| id: "nav.store.purchaseOrder", | |||
| @@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | |||
| path: "/inventory", | |||
| }, | |||
| { | |||
| id: "nav.store.itemTracing", | |||
| icon: <QrCodeIcon />, | |||
| labelKey: "nav.store.itemTracing", | |||
| requiredAbility: [AUTH.ITEM_TRACING], | |||
| path: "/itemTracing", | |||
| }, | |||
| { | |||
| id: "nav.store.stockTake", | |||
| icon: <AssignmentTurnedIn />, | |||
| @@ -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" | |||
| } | |||
| @@ -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", | |||
| @@ -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": "最近異動" | |||
| } | |||
| @@ -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": "上架掃碼", | |||
| @@ -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; | |||
| }; | |||