| @@ -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(); | 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") { | if (status && String(status).trim() !== "" && String(status) !== "All") { | ||||
| params.set("status", String(status).trim()); | params.set("status", String(status).trim()); | ||||
| @@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { headers } from "next/headers"; | import { headers } from "next/headers"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import { ServerFetchError } from "./serverFetchError"; | |||||
| export { ServerFetchError }; | |||||
| export interface BlobResponse { | export interface BlobResponse { | ||||
| filename: string; | filename: string; | ||||
| @@ -21,16 +24,6 @@ export interface searchParamsProps { | |||||
| searchParams: { [key: string]: string | string[] | undefined }; | 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) { | export async function serverFetchWithNoContent(...args: FetchParams) { | ||||
| const response = await serverFetch(...args); | 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", | ADMIN: "ADMIN", | ||||
| STOCK: "STOCK", | STOCK: "STOCK", | ||||
| INVENTORY_ADJUST: "INVENTORY_ADJUST", | INVENTORY_ADJUST: "INVENTORY_ADJUST", | ||||
| ITEM_TRACING: "ITEM_TRACING", | |||||
| PURCHASE: "PURCHASE", | PURCHASE: "PURCHASE", | ||||
| STOCK_TAKE: "STOCK_TAKE", | STOCK_TAKE: "STOCK_TAKE", | ||||
| STOCK_IN_BIND: "STOCK_IN_BIND", | STOCK_IN_BIND: "STOCK_IN_BIND", | ||||
| @@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = { | |||||
| "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", | "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", | ||||
| "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", | "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", | ||||
| "/inventory": "nav.breadcrumb.inventory", | "/inventory": "nav.breadcrumb.inventory", | ||||
| "/itemTracing": "nav.breadcrumb.itemTracing", | |||||
| "/settings/importTesting": "nav.breadcrumb.importTesting", | "/settings/importTesting": "nav.breadcrumb.importTesting", | ||||
| "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", | "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", | ||||
| "/do": "nav.deliveryOrder", | "/do": "nav.deliveryOrder", | ||||
| @@ -147,8 +147,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| setTab(newTab); | setTab(newTab); | ||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", String(newTab)); | 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("ticketNo"); | ||||
| params.delete("targetDate"); | params.delete("targetDate"); | ||||
| } | } | ||||
| @@ -391,10 +391,13 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={3}> | <TabPanel value={tab} index={3}> | ||||
| <GoodPickExecutionWorkbenchRecord | <GoodPickExecutionWorkbenchRecord | ||||
| key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`} | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| listScope="all" | listScope="all" | ||||
| a4Printer={a4Printer} | a4Printer={a4Printer} | ||||
| labelPrinter={labelPrinter} | labelPrinter={labelPrinter} | ||||
| initialTicketNo={urlTicketNo} | |||||
| initialTargetDate={urlTargetDate} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={4}> | <TabPanel value={tab} index={4}> | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Accordion, | Accordion, | ||||
| AccordionDetails, | 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(() => { | const handleBackToList = useCallback(() => { | ||||
| setShowDetailView(false); | setShowDetailView(false); | ||||
| setSelectedRecord(null); | 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"; | "use client"; | ||||
| import { PickOrderResult } from "@/app/api/pickOrder"; | import { PickOrderResult } from "@/app/api/pickOrder"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useSearchParams } from "next/navigation"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { | import { | ||||
| @@ -52,6 +53,20 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation("jo"); | 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 { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -61,7 +76,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | ||||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | ||||
| const [searchQuery, setSearchQuery] = 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 [totalCount, setTotalCount] = useState<number>(); | ||||
| const [isAssigning, setIsAssigning] = useState(false); | const [isAssigning, setIsAssigning] = useState(false); | ||||
| const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | ||||
| @@ -70,6 +85,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| const [hasDataTab0, setHasDataTab0] = useState(false); | const [hasDataTab0, setHasDataTab0] = useState(false); | ||||
| const [hasDataTab1, setHasDataTab1] = useState(false); | const [hasDataTab1, setHasDataTab1] = useState(false); | ||||
| const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | ||||
| useEffect(() => { | |||||
| if (urlTabIndex != null) setTabIndex(urlTabIndex); | |||||
| }, [urlTabIndex]); | |||||
| // Add printer selection state | // Add printer selection state | ||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | ||||
| @@ -492,6 +511,8 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| selectedPrinter={selectedPrinter} | selectedPrinter={selectedPrinter} | ||||
| printQty={printQty} | printQty={printQty} | ||||
| initialPickOrderCode={urlPickOrderCode} | |||||
| initialTargetDate={urlTargetDate} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && <MaterialPickStatusTable />} | {tabIndex === 2 && <MaterialPickStatusTable />} | ||||
| @@ -58,6 +58,8 @@ interface Props { | |||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| selectedPrinter?: PrinterCombo | null; | selectedPrinter?: PrinterCombo | null; | ||||
| printQty?: number; | printQty?: number; | ||||
| initialPickOrderCode?: string; | |||||
| initialTargetDate?: string; | |||||
| } | } | ||||
| // 修改:已完成的 Job Order Pick Order 接口 | // 修改:已完成的 Job Order Pick Order 接口 | ||||
| @@ -112,11 +114,13 @@ interface LotDetail { | |||||
| match_status: string | null; | match_status: string | null; | ||||
| } | } | ||||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| filterArgs, | filterArgs, | ||||
| printerCombo, | printerCombo, | ||||
| selectedPrinter: selectedPrinterProp, | selectedPrinter: selectedPrinterProp, | ||||
| printQty: printQtyProp | |||||
| printQty: printQtyProp, | |||||
| initialPickOrderCode, | |||||
| initialTargetDate, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -135,15 +139,23 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| const [detailLotDataLoading, setDetailLotDataLoading] = useState(false); | 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[]>([]); | const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]); | ||||
| // Use props with fallback | // Use props with fallback | ||||
| const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | ||||
| const printQty = printQtyProp ?? 1; | const printQty = printQtyProp ?? 1; | ||||
| const pickRecordPrintInFlightRef = useRef(false); | const pickRecordPrintInFlightRef = useRef(false); | ||||
| const initialDetailOpenedRef = useRef(false); | |||||
| // 修改:分页状态 | // 修改:分页状态 | ||||
| const [paginationController, setPaginationController] = useState({ | const [paginationController, setPaginationController] = useState({ | ||||
| @@ -370,6 +382,25 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| }, [fetchLotDetailsData]); | }, [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(() => { | const handleBackToList = useCallback(() => { | ||||
| setShowDetailView(false); | setShowDetailView(false); | ||||
| @@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Storefront />, | icon: <Storefront />, | ||||
| labelKey: "nav.storeManagement", | labelKey: "nav.storeManagement", | ||||
| path: "", | 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: [ | children: [ | ||||
| { | { | ||||
| id: "nav.store.purchaseOrder", | id: "nav.store.purchaseOrder", | ||||
| @@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | ||||
| path: "/inventory", | path: "/inventory", | ||||
| }, | }, | ||||
| { | |||||
| id: "nav.store.itemTracing", | |||||
| icon: <QrCodeIcon />, | |||||
| labelKey: "nav.store.itemTracing", | |||||
| requiredAbility: [AUTH.ITEM_TRACING], | |||||
| path: "/itemTracing", | |||||
| }, | |||||
| { | { | ||||
| id: "nav.store.stockTake", | id: "nav.store.stockTake", | ||||
| icon: <AssignmentTurnedIn />, | 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.purchaseOrder": "Purchase Order", | ||||
| "nav.store.pickOrder": "Pick Order", | "nav.store.pickOrder": "Pick Order", | ||||
| "nav.store.inventoryLedger": "View item In-out And inventory Ledger", | "nav.store.inventoryLedger": "View item In-out And inventory Ledger", | ||||
| "nav.store.itemTracing": "Item Tracing", | |||||
| "nav.store.stockTake": "Stock Take Management", | "nav.store.stockTake": "Stock Take Management", | ||||
| "nav.store.stockIssue": "Stock Issue", | "nav.store.stockIssue": "Stock Issue", | ||||
| "nav.store.putAwayScan": "Put Away Scan", | "nav.store.putAwayScan": "Put Away Scan", | ||||
| @@ -81,6 +82,7 @@ | |||||
| "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", | "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", | ||||
| "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", | "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", | ||||
| "nav.breadcrumb.inventory": "Inventory", | "nav.breadcrumb.inventory": "Inventory", | ||||
| "nav.breadcrumb.itemTracing": "Item Tracing", | |||||
| "nav.breadcrumb.importTesting": "Import Testing", | "nav.breadcrumb.importTesting": "Import Testing", | ||||
| "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", | "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", | ||||
| "nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick", | "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.home": "總覽", | ||||
| "nav.breadcrumb.importTesting": "匯入測試", | "nav.breadcrumb.importTesting": "匯入測試", | ||||
| "nav.breadcrumb.inventory": "存貨", | "nav.breadcrumb.inventory": "存貨", | ||||
| "nav.breadcrumb.itemTracing": "批號追溯", | |||||
| "nav.breadcrumb.joEdit": "工單詳情", | "nav.breadcrumb.joEdit": "工單詳情", | ||||
| "nav.breadcrumb.joTesting": "工單測試", | "nav.breadcrumb.joTesting": "工單測試", | ||||
| "nav.breadcrumb.joWorkbench": "工單工作台", | "nav.breadcrumb.joWorkbench": "工單工作台", | ||||
| @@ -91,6 +92,7 @@ | |||||
| "nav.store.doWorkbench": "成品出倉", | "nav.store.doWorkbench": "成品出倉", | ||||
| "nav.store.finishedGoodManagement": "成品出倉管理", | "nav.store.finishedGoodManagement": "成品出倉管理", | ||||
| "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", | "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", | ||||
| "nav.store.itemTracing": "批號追溯", | |||||
| "nav.store.pickOrder": "提料單", | "nav.store.pickOrder": "提料單", | ||||
| "nav.store.purchaseOrder": "採購單", | "nav.store.purchaseOrder": "採購單", | ||||
| "nav.store.putAwayScan": "上架掃碼", | "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; | |||||
| }; | |||||