Procházet zdrojové kódy

item trace

production
tommy před 17 hodinami
rodič
revize
8f7ff13997
61 změnil soubory, kde provedl 14568 přidání a 22 odebrání
  1. +44
    -0
      src/app/(main)/itemTracing/page.tsx
  2. +24
    -0
      src/app/api/itemTracing/actions.ts
  3. +493
    -0
      src/app/api/itemTracing/index.ts
  4. +211
    -0
      src/app/api/itemTracing/schema.ts
  5. +9
    -2
      src/app/api/jo/actions.ts
  6. +3
    -10
      src/app/utils/fetchUtil.ts
  7. +21
    -0
      src/app/utils/serverFetchError.ts
  8. +1
    -0
      src/authorities.ts
  9. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  10. +5
    -2
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  11. +11
    -1
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  12. +151
    -0
      src/components/ItemTracing/ItemTracing.tsx
  13. +90
    -0
      src/components/ItemTracing/ItemTracingDocLink.tsx
  14. +797
    -0
      src/components/ItemTracing/ItemTracingFlowGraph.tsx
  15. +158
    -0
      src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
  16. +11
    -0
      src/components/ItemTracing/ItemTracingLoading.tsx
  17. +790
    -0
      src/components/ItemTracing/ItemTracingLocations.tsx
  18. +50
    -0
      src/components/ItemTracing/ItemTracingLotTraceLink.tsx
  19. +265
    -0
      src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx
  20. +142
    -0
      src/components/ItemTracing/ItemTracingScanBar.tsx
  21. +915
    -0
      src/components/ItemTracing/ItemTracingSections.tsx
  22. +100
    -0
      src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx
  23. +291
    -0
      src/components/ItemTracing/ItemTracingSummary.tsx
  24. +58
    -0
      src/components/ItemTracing/TraceFlowEdge.tsx
  25. +843
    -0
      src/components/ItemTracing/TraceFlowNodes.tsx
  26. +277
    -0
      src/components/ItemTracing/buildExtendedTraceGraphNodes.ts
  27. +632
    -0
      src/components/ItemTracing/buildJoPreludeGraphNodes.ts
  28. +172
    -0
      src/components/ItemTracing/buildLocationBlockGraphNodes.ts
  29. +169
    -0
      src/components/ItemTracing/buildProductionGraphNodes.ts
  30. +323
    -0
      src/components/ItemTracing/buildReactFlowGraph.ts
  31. +1210
    -0
      src/components/ItemTracing/buildTraceGraphNodes.ts
  32. +33
    -0
      src/components/ItemTracing/compileTraceGraph.ts
  33. +905
    -0
      src/components/ItemTracing/exportItemLotTraceXlsx.ts
  34. +5
    -0
      src/components/ItemTracing/index.ts
  35. +113
    -0
      src/components/ItemTracing/itemTracingTableFilters.tsx
  36. +316
    -0
      src/components/ItemTracing/mergeLocationScopedData.ts
  37. +274
    -0
      src/components/ItemTracing/traceDoGroupLayout.ts
  38. +155
    -0
      src/components/ItemTracing/traceDocLinkUtils.ts
  39. +38
    -0
      src/components/ItemTracing/traceFlowConstants.ts
  40. +395
    -0
      src/components/ItemTracing/traceFlowEdgeLayout.ts
  41. +200
    -0
      src/components/ItemTracing/traceFlowLayout.ts
  42. +223
    -0
      src/components/ItemTracing/traceFlowNodeUtils.ts
  43. +291
    -0
      src/components/ItemTracing/traceGraphLabels.ts
  44. +402
    -0
      src/components/ItemTracing/traceGraphLayout.ts
  45. +47
    -0
      src/components/ItemTracing/traceGraphSearch.ts
  46. +1169
    -0
      src/components/ItemTracing/traceGraphSemantics.ts
  47. +178
    -0
      src/components/ItemTracing/traceLabelUtils.ts
  48. +14
    -0
      src/components/ItemTracing/traceNavigationUtils.ts
  49. +213
    -0
      src/components/ItemTracing/traceNodeFactory.ts
  50. +200
    -0
      src/components/ItemTracing/tracePresentationAdapter.ts
  51. +174
    -0
      src/components/ItemTracing/tracePutawayUtils.ts
  52. +38
    -0
      src/components/ItemTracing/traceQtyUtils.ts
  53. +166
    -0
      src/components/ItemTracing/traceStockTakeUtils.ts
  54. +22
    -1
      src/components/Jodetail/JodetailSearch.tsx
  55. +36
    -5
      src/components/Jodetail/completeJobOrderRecord.tsx
  56. +8
    -1
      src/components/NavigationContent/NavigationContent.tsx
  57. +312
    -0
      src/i18n/en/itemTracing.json
  58. +2
    -0
      src/i18n/en/navigation.json
  59. +313
    -0
      src/i18n/zh/itemTracing.json
  60. +2
    -0
      src/i18n/zh/navigation.json
  61. +57
    -0
      src/utils/traceDoOutboundExtra.ts

+ 44
- 0
src/app/(main)/itemTracing/page.tsx Zobrazit soubor

@@ -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;

+ 24
- 0
src/app/api/itemTracing/actions.ts Zobrazit soubor

@@ -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.
*/

+ 493
- 0
src/app/api/itemTracing/index.ts Zobrazit soubor

@@ -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;
}

+ 211
- 0
src/app/api/itemTracing/schema.ts Zobrazit soubor

@@ -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;
};

+ 9
- 2
src/app/api/jo/actions.ts Zobrazit soubor

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

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

if (dateOnly) {
params.set("date", dateOnly);
}
if (status && String(status).trim() !== "" && String(status) !== "All") {
params.set("status", String(status).trim());


+ 3
- 10
src/app/utils/fetchUtil.ts Zobrazit soubor

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

export { ServerFetchError };

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

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

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

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



+ 21
- 0
src/app/utils/serverFetchError.ts Zobrazit soubor

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

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

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

+ 1
- 0
src/authorities.ts Zobrazit soubor

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


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Zobrazit soubor

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


+ 5
- 2
src/components/DoWorkbench/DoWorkbenchTabs.tsx Zobrazit soubor

@@ -147,8 +147,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
setTab(newTab);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", String(newTab));
/* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */
if (newTab !== 2) {
/* ticketNo / targetDate deep-link for Finished Good Record tabs */
if (newTab !== 2 && newTab !== 3) {
params.delete("ticketNo");
params.delete("targetDate");
}
@@ -391,10 +391,13 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
</TabPanel>
<TabPanel value={tab} index={3}>
<GoodPickExecutionWorkbenchRecord
key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`}
printerCombo={printerCombo}
listScope="all"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
initialTicketNo={urlTicketNo}
initialTargetDate={urlTargetDate}
/>
</TabPanel>
<TabPanel value={tab} index={4}>


+ 11
- 1
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx Zobrazit soubor

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

import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
AccordionDetails,
@@ -507,6 +507,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
[],
);

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

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


+ 151
- 0
src/components/ItemTracing/ItemTracing.tsx Zobrazit soubor

@@ -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;

+ 90
- 0
src/components/ItemTracing/ItemTracingDocLink.tsx Zobrazit soubor

@@ -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;

+ 797
- 0
src/components/ItemTracing/ItemTracingFlowGraph.tsx Zobrazit soubor

@@ -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;

+ 158
- 0
src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx Zobrazit soubor

@@ -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;

+ 11
- 0
src/components/ItemTracing/ItemTracingLoading.tsx Zobrazit soubor

@@ -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;

+ 790
- 0
src/components/ItemTracing/ItemTracingLocations.tsx Zobrazit soubor

@@ -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;

+ 50
- 0
src/components/ItemTracing/ItemTracingLotTraceLink.tsx Zobrazit soubor

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

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

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

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

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

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

export default ItemTracingLotTraceLink;

+ 265
- 0
src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx Zobrazit soubor

@@ -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;

+ 142
- 0
src/components/ItemTracing/ItemTracingScanBar.tsx Zobrazit soubor

@@ -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;

+ 915
- 0
src/components/ItemTracing/ItemTracingSections.tsx Zobrazit soubor

@@ -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;

+ 100
- 0
src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx Zobrazit soubor

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

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

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

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

if (!stages.length) return null;

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

export default ItemTracingStockTakeLifecycle;

+ 291
- 0
src/components/ItemTracing/ItemTracingSummary.tsx Zobrazit soubor

@@ -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;

+ 58
- 0
src/components/ItemTracing/TraceFlowEdge.tsx Zobrazit soubor

@@ -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,
};

+ 843
- 0
src/components/ItemTracing/TraceFlowNodes.tsx Zobrazit soubor

@@ -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,
};

+ 277
- 0
src/components/ItemTracing/buildExtendedTraceGraphNodes.ts Zobrazit soubor

@@ -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));
};

+ 632
- 0
src/components/ItemTracing/buildJoPreludeGraphNodes.ts Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

return nodes;
};

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

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

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

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

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

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

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

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

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

return nodes;
};

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

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

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

+ 172
- 0
src/components/ItemTracing/buildLocationBlockGraphNodes.ts Zobrazit soubor

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

import {

buildTraceGraphNodes,

TraceGraphBuildScope,

TraceGraphDetailLabels,

TraceGraphNode,

} from "./buildTraceGraphNodes";

import {

buildExtendedTraceGraphNodes,

ExtendedTraceGraphLabels,

} from "./buildExtendedTraceGraphNodes";



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

block.warehouseLines

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

.filter(Boolean)

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



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

const uom = block.uom ?? "";

return {

lot: {

inventoryLotId: block.inventoryLotId,

lotNo: block.lotNo,

itemId: 0,

itemCode: block.itemCode,

itemName: block.itemName ?? "",

expiryDate: block.expiryDate ?? null,

productionDate: block.productionDate ?? null,

stockInDate: block.stockInDate ?? null,

uom,

primaryStockInLineId: block.stockInLineId,

},

warehouseLines: block.warehouseLines,

origins: block.origins,

purchaseEvents: block.purchaseEvents ?? [],

qcResults: block.qcResults ?? [],

putawayEvents: block.putawayEvents ?? [],

movements: block.movements ?? [],

outboundUsage: block.outboundUsage ?? [],

stockTakeEvents: block.stockTakeEvents ?? [],

adjustments: block.adjustments ?? [],

transfers: block.transfers ?? [],

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

joPrelude: null,

productionSteps: [],

byproductLots: [],

openMovements: block.openMovements ?? [],

failEvents: block.failEvents ?? [],

doDeliveries: block.doDeliveries ?? [],

replenishmentEvents: block.replenishmentEvents ?? [],

returnEvents: block.returnEvents ?? [],

lotRelations: [],

alternateLocations: [],

locationBlocks: [],

traceGraph: null,

};

};



export const buildLocationBlockGraphNodes = (

block: ItemLotTraceLocationBlock,

labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,

): TraceGraphNode[] => {

const wh = blockWarehouseCode(block);

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

const synthetic = blockToTraceResponse(block);

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

const extNodes =

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

? buildExtendedTraceGraphNodes(synthetic, labels, scope)

: [];



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

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

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

});

};



export const buildAllLocationBlockGraphNodes = (

blocks: ItemLotTraceLocationBlock[],

labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels,

): TraceGraphNode[] =>

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


+ 169
- 0
src/components/ItemTracing/buildProductionGraphNodes.ts Zobrazit soubor

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

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

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

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

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

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

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

return nodes;
};

+ 323
- 0
src/components/ItemTracing/buildReactFlowGraph.ts Zobrazit soubor

@@ -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>;

+ 1210
- 0
src/components/ItemTracing/buildTraceGraphNodes.ts
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 33
- 0
src/components/ItemTracing/compileTraceGraph.ts Zobrazit soubor

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

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

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

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

+ 905
- 0
src/components/ItemTracing/exportItemLotTraceXlsx.ts Zobrazit soubor

@@ -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));
};

+ 5
- 0
src/components/ItemTracing/index.ts Zobrazit soubor

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

+ 113
- 0
src/components/ItemTracing/itemTracingTableFilters.tsx Zobrazit soubor

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

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

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

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

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

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

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

+ 316
- 0
src/components/ItemTracing/mergeLocationScopedData.ts Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 274
- 0
src/components/ItemTracing/traceDoGroupLayout.ts Zobrazit soubor

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

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

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

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

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

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

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

return result;
};

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

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

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

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

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

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

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

if (!groupNodes.length) return nodes;

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

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

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

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

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

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

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

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

if (!groupNodes.length) return nodes;

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

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

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

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

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

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

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

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

+ 155
- 0
src/components/ItemTracing/traceDocLinkUtils.ts Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 38
- 0
src/components/ItemTracing/traceFlowConstants.ts Zobrazit soubor

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

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

+ 395
- 0
src/components/ItemTracing/traceFlowEdgeLayout.ts Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

return { routed, incomingCount, outgoingCount };
};

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

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

const corridorCandidates: RoutedTraceFlowEdge[] = [];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 200
- 0
src/components/ItemTracing/traceFlowLayout.ts Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

if (n === 1) return COLUMN_WIDTH_MIN;

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

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

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

const innerW = innerCellWidth;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 223
- 0
src/components/ItemTracing/traceFlowNodeUtils.ts Zobrazit soubor

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

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

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

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

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

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

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

+ 291
- 0
src/components/ItemTracing/traceGraphLabels.ts Zobrazit soubor

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

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

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

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

+ 402
- 0
src/components/ItemTracing/traceGraphLayout.ts Zobrazit soubor

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

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

export type PreProductionPhase = "MATERIAL_PICK";

export type ProductionPhase = "PRODUCTION";

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

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

export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 47
- 0
src/components/ItemTracing/traceGraphSearch.ts Zobrazit soubor

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

const normalize = (value: string) => value.trim().toLowerCase();

const nodeSearchHaystack = (
node: TraceGraphLayoutNode,
kindLabel: string,
): string => {
const parts: string[] = [
node.title,
node.subtitle,
node.refCode ?? "",
node.traceLotNo ?? "",
node.traceItemCode ?? "",
node.consoCode ?? "",
node.meta ?? "",
node.timestamp ?? "",
node.categoryLabel ?? "",
node.refType ?? "",
node.warehouseCode ?? "",
node.transferFromWarehouse ?? "",
node.transferToWarehouse ?? "",
kindLabel,
node.qty != null ? String(node.qty) : "",
...node.details.flatMap((field) =>
[field.label, field.value, field.linkCode ?? ""].filter(Boolean),
),
];
return normalize(parts.join(" "));
};

export const searchTraceGraphNodes = (
nodes: TraceGraphLayoutNode[],
query: string,
kindLabel: (kind: TraceGraphNodeKind) => string,
): string[] => {
const normalizedQuery = normalize(query);
if (!normalizedQuery) return [];

return nodes
.filter((node) =>
nodeSearchHaystack(node, kindLabel(node.kind)).includes(normalizedQuery),
)
.sort((a, b) => a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex)
.map((node) => node.id);
};

+ 1169
- 0
src/components/ItemTracing/traceGraphSemantics.ts
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 178
- 0
src/components/ItemTracing/traceLabelUtils.ts Zobrazit soubor

@@ -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,
});

+ 14
- 0
src/components/ItemTracing/traceNavigationUtils.ts Zobrazit soubor

@@ -0,0 +1,14 @@
export type TraceNavigateParams = {
stockInLineId?: number;
lotNo?: string;
itemCode?: string;
};

export const buildItemTracingHref = (params: TraceNavigateParams): string => {
const q = new URLSearchParams();
if (params.stockInLineId != null) q.set("stockInLineId", String(params.stockInLineId));
if (params.lotNo?.trim()) q.set("lotNo", params.lotNo.trim());
if (params.itemCode?.trim()) q.set("itemCode", params.itemCode.trim());
const qs = q.toString();
return qs ? `/itemTracing?${qs}` : "/itemTracing";
};

+ 213
- 0
src/components/ItemTracing/traceNodeFactory.ts Zobrazit soubor

@@ -0,0 +1,213 @@
import dayjs from "dayjs";
import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
import {
TraceGraphDetailField,
TraceGraphDocLinkKind,
TraceGraphNode,
} from "./buildTraceGraphNodes";
import { formatQty } from "./traceQtyUtils";
import { pickStatusValueColor } from "./traceLabelUtils";
import { normalizeTargetDateForLink } from "./traceDocLinkUtils";

export type MaterialPickLabels = {
nodeMaterialPick: string;
categoryPick: string;
pickOrder: string;
jobOrder: string;
detailMaterial: string;
detailLot: string;
detailQty: string;
detailType: string;
detailAssignedStep: string;
detailPickTargetDate: string;
detailTime: string;
processingStatus: string;
matchStatus: string;
tr: {
processingStatus: (code: string | null | undefined) => string;
matchStatus: (code: string | null | undefined) => string;
};
};

export const pickStatusDetailFields = (
labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">,
processingStatus?: string | null,
matchStatus?: string | null,
opts?: { always?: boolean },
): TraceGraphDetailField[] => {
const always = opts?.always === true;
const rows: TraceGraphDetailField[] = [];
if (always || processingStatus?.trim()) {
rows.push(
field(labels.processingStatus, labels.tr.processingStatus(processingStatus), {
valueColor: pickStatusValueColor(processingStatus),
}),
);
}
if (always || matchStatus?.trim()) {
rows.push(
field(labels.matchStatus, labels.tr.matchStatus(matchStatus), {
valueColor: pickStatusValueColor(matchStatus),
}),
);
}
return rows;
};

export const pickStatusNodeLabels = (
labels: Pick<MaterialPickLabels, "tr">,
processingStatus?: string | null,
matchStatus?: string | null,
opts?: { always?: boolean },
): Pick<
TraceGraphNode,
"processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus"
> => {
const always = opts?.always === true;
const showProcessing = always || Boolean(processingStatus?.trim());
const showMatch = always || Boolean(matchStatus?.trim());
return {
processingStatusLabel: showProcessing
? labels.tr.processingStatus(processingStatus)
: undefined,
matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined,
processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined,
matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined,
};
};

export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—");

export const field = (
label: string,
value: string | null | undefined,
extra?: Partial<TraceGraphDetailField>,
): TraceGraphDetailField => ({
label,
value: fmt(value),
...extra,
});

/** Omit detail row when value is blank (used for optional remarks, etc.). */
export const fieldIf = (
label: string,
value: string | number | null | undefined,
extra?: Partial<TraceGraphDetailField>,
): TraceGraphDetailField | null => {
const raw = value == null ? "" : String(value).trim();
if (!raw) return null;
return field(label, raw, extra);
};

export const detailsOf = (
...rows: Array<TraceGraphDetailField | null | undefined | false>
): TraceGraphDetailField[] => rows.filter((row): row is TraceGraphDetailField => Boolean(row));

export const parseSortKey = (ts: string | null | undefined, seq: number): number => {
if (!ts?.trim()) return seq;
const d = dayjs(ts);
return d.isValid() ? d.valueOf() : seq;
};

/** Expiry is date-only in master data; sort at end of that calendar day (23:59:59). */
export const parseExpirySortKey = (expiry: string | null | undefined, seq: number): number => {
if (!expiry?.trim()) return seq;
const d = dayjs(expiry.trim().slice(0, 10));
return d.isValid() ? d.endOf("day").valueOf() : seq;
};

export const docLinkFromOriginType = (type: string): TraceGraphDocLinkKind | undefined => {
const t = type.toUpperCase();
if (t === "PO") return "po";
if (t === "JO") return "jo";
return undefined;
};

export const docLinkFromRefType = (refType: string): TraceGraphDocLinkKind | undefined => {
const t = refType.toUpperCase();
if (t === "PO") return "po";
if (t === "JO") return "jo";
if (t === "PICK" || t === "DO") return "pick";
if (t === "JO_PICK") return "jodetail";
return undefined;
};

/** Semi-finished / nested JO output used as a material for another JO. */
export const isJoProducedMaterial = (m: ItemLotTraceMaterialInput): boolean =>
(m.productionSteps?.length ?? 0) > 0 ||
Boolean(m.nestedJoPrelude) ||
m.stockInOrigin?.type?.trim().toUpperCase() === "JO";

export type MaterialPickNodeContext = {
nextSeq: () => number;
pickOrderTargetDate?: (pickOrderCode: string) => string | undefined;
};

export const createMaterialPickNode = (
m: ItemLotTraceMaterialInput,
index: number,
labels: MaterialPickLabels,
ctx: MaterialPickNodeContext,
feedsProductionScopeLotNo?: string,
): TraceGraphNode => {
const matUom = m.materialUom?.trim() || "";
const pickTitle = m.pickOrderCode || labels.nodeMaterialPick;
const stepLabel = m.assignedStepName?.trim()
? m.bomProcessSeqNo != null
? `${m.bomProcessSeqNo}. ${m.assignedStepName}`
: m.assignedStepName
: "";
const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · ");
const pickTargetDate = normalizeTargetDateForLink(
ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""),
);
const pickedDate = normalizeTargetDateForLink(m.pickedAt);
const linkTargetDate = pickTargetDate || pickedDate;
return {
id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`,
kind: "MATERIAL_PICK",
timestamp: m.pickedAt,
sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()),
title: pickTitle,
subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "),
qty: m.materialQty,
uom: matUom,
meta: itemLabel || undefined,
refCode: m.pickOrderCode,
refId: m.pickOrderId,
consoCode: m.consoCode,
jobOrderCode: m.jobOrderCode?.trim() || undefined,
docLinkKind: m.pickOrderCode ? "jodetail" : undefined,
docLinkTargetDate: linkTargetDate,
traceLotNo: m.materialLotNo,
traceItemCode: m.materialItemCode,
bomProcessId: m.bomProcessId,
bomProcessSeqNo: m.bomProcessSeqNo,
assignedStepName: m.assignedStepName,
feedsProductionScopeLotNo,
categoryLabel: labels.categoryPick,
details: [
field(labels.pickOrder, m.pickOrderCode, {
linkKind: "jodetail",
linkCode: m.pickOrderCode,
linkId: m.pickOrderId,
consoCode: m.consoCode,
linkTargetDate,
}),
field(labels.detailPickTargetDate, pickTargetDate || pickedDate),
field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`),
field(labels.detailLot, m.materialLotNo),
field(labels.detailQty, formatQty(m.materialQty, matUom)),
field(labels.detailType, labels.nodeMaterialPick),
field(labels.detailAssignedStep, stepLabel || "—"),
field(labels.jobOrder, m.jobOrderCode, {
linkKind: "jo",
linkCode: m.jobOrderCode,
linkId: m.jobOrderId,
}),
...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus),
field(labels.detailTime, m.pickedAt),
],
...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus),
};
};

+ 200
- 0
src/components/ItemTracing/tracePresentationAdapter.ts Zobrazit soubor

@@ -0,0 +1,200 @@
import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
import type { TraceGraphLayoutNode } from "./traceGraphLayout";
import type { TraceGraphNodeKind } from "./buildTraceGraphNodes";
import { mergeScopedMovements } from "./mergeLocationScopedData";

export type TraceMovementRow = {
id: string;
direction: string;
qty: number;
movementType: string;
refType: string;
refCode: string;
refId: number | null;
warehouseCode: string;
handledBy: string;
timestamp: string | null;
remarks: string;
};

export type TraceJoPickRow = {
id: string;
pickOrderCode: string;
pickOrderId: number | null;
consoCode: string;
materialItemCode: string;
materialLotNo: string;
qty: number;
uom: string;
pickedAt: string | null;
bomProcessId: number | null;
assignedStepName: string;
/** YYYY-MM-DD for jodetail deep links. */
targetDate?: string;
};

export type TraceProductionRow = {
id: string;
stepName: string;
description: string;
equipmentCode: string;
equipmentName: string;
operatorName: string;
startTime: string | null;
endTime: string | null;
outputQty: number;
scrapQty: number;
defectQty: number;
status: string;
seqNo: number | null;
bomProcessId: number | null;
traceLotNo: string | null;
};

const detailValue = (node: TraceGraphLayoutNode, labelIncludes: string): string => {
const row = node.details.find((d) => d.label.includes(labelIncludes));
return row?.value?.trim() || "—";
};

const movementKinds: TraceGraphNodeKind[] = [
"IN",
"OUT",
"RECEIPT",
"OPEN",
"RETURN",
"REPACK",
];

export const deriveMovementRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
data: ItemLotTraceResponse,
): TraceMovementRow[] => {
const fromGraph = nodes
.filter((n) => movementKinds.includes(n.kind))
.map((n) => ({
id: n.id,
direction: n.kind === "OUT" || n.kind === "RETURN" ? "OUT" : "IN",
qty: n.qty ?? 0,
movementType: n.subtitle?.trim() || n.kind,
refType: n.refType?.trim() || "",
refCode: n.refCode?.trim() || "",
refId: n.refId ?? null,
warehouseCode: n.warehouseCode?.trim() || (detailValue(n, "Warehouse") !== "—" ? detailValue(n, "Warehouse") : n.meta?.split(" · ")[1] ?? ""),
handledBy: detailValue(n, "Handler") !== "—" ? detailValue(n, "Handler") : n.meta?.split(" · ")[0] ?? "",
timestamp: n.timestamp,
remarks: detailValue(n, "Remarks"),
}));

if (fromGraph.length > 0) return fromGraph;

return mergeScopedMovements(data).map((m) => ({
id: m.rowKey,
direction: m.direction,
qty: m.qty,
movementType: m.movementType,
refType: m.refType,
refCode: m.refCode,
refId: m.refId,
warehouseCode: m.scopeWarehouseCode,
handledBy: m.handledBy,
timestamp: m.timestamp,
remarks: m.remarks,
}));
};

export const deriveJoPickRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
fallbackInputs: NonNullable<ItemLotTraceResponse["joPrelude"]>["materialInputs"] | undefined,
): TraceJoPickRow[] => {
const picks = nodes.filter((n) => n.kind === "MATERIAL_PICK");
if (picks.length > 0) {
return picks.map((n) => ({
id: n.id,
pickOrderCode: n.refCode?.trim() || n.title?.trim() || "",
pickOrderId: n.refId ?? null,
consoCode: n.consoCode?.trim() || "",
materialItemCode: n.traceItemCode?.trim() || "",
materialLotNo: n.traceLotNo?.trim() || "",
qty: n.qty ?? 0,
uom: n.uom?.trim() || "",
pickedAt: n.timestamp,
bomProcessId: n.bomProcessId ?? null,
assignedStepName: n.assignedStepName?.trim() || "",
targetDate: n.docLinkTargetDate?.trim() || undefined,
}));
}

return (fallbackInputs ?? []).map((m, i) => ({
id: `pick-api-${i}-${m.pickOrderCode}-${m.materialLotNo}`,
pickOrderCode: m.pickOrderCode,
pickOrderId: m.pickOrderId,
consoCode: m.consoCode,
materialItemCode: m.materialItemCode,
materialLotNo: m.materialLotNo,
qty: m.materialQty,
uom: m.materialUom ?? "",
pickedAt: m.pickedAt,
bomProcessId: m.bomProcessId ?? null,
assignedStepName: m.assignedStepName ?? "",
targetDate: undefined,
}));
};

export const deriveProductionRowsFromGraph = (
nodes: TraceGraphLayoutNode[],
fallback: ItemLotTraceResponse["productionSteps"],
): TraceProductionRow[] => {
const steps = nodes.filter((n) => n.kind === "PRODUCTION_STEP");
if (steps.length > 0) {
return steps.map((n) => ({
id: n.id,
stepName: n.title?.trim() || detailValue(n, "Step"),
description: detailValue(n, "Remarks"),
equipmentCode: "",
equipmentName: detailValue(n, "Equipment"),
operatorName: detailValue(n, "Handler"),
startTime: null,
endTime: n.timestamp,
outputQty: n.qty ?? 0,
scrapQty: 0,
defectQty: 0,
status: detailValue(n, "Status"),
seqNo: n.bomProcessSeqNo ?? null,
bomProcessId: n.bomProcessId ?? null,
traceLotNo: n.traceLotNo ?? null,
}));
}

return fallback.map((s) => ({
id: `prod-api-${s.processLineId}`,
stepName: s.stepName,
description: s.description,
equipmentCode: s.equipmentCode,
equipmentName: s.equipmentName,
operatorName: s.operatorName,
startTime: s.startTime,
endTime: s.endTime,
outputQty: s.outputQty,
scrapQty: s.scrapQty,
defectQty: s.defectQty,
status: s.status,
seqNo: s.seqNo,
bomProcessId: s.bomProcessId ?? null,
traceLotNo: null,
}));
};

export type TracePresentationRows = {
movements: TraceMovementRow[];
joPicks: TraceJoPickRow[];
production: TraceProductionRow[];
};

export const buildTracePresentationRows = (
nodes: TraceGraphLayoutNode[],
data: ItemLotTraceResponse,
): TracePresentationRows => ({
movements: deriveMovementRowsFromGraph(nodes, data),
joPicks: deriveJoPickRowsFromGraph(nodes, data.joPrelude?.materialInputs),
production: deriveProductionRowsFromGraph(nodes, data.productionSteps),
});

+ 174
- 0
src/components/ItemTracing/tracePutawayUtils.ts Zobrazit soubor

@@ -0,0 +1,174 @@
import { TraceLabelTranslator } from "./traceLabelUtils";

/** Matches PO receiving UI: only `completed` means 已上架. */
const COMPLETED_PUTAWAY_STATUSES = new Set(["completed", "complete"]);

export const normRefType = (refType: string | null | undefined): string =>
(refType ?? "").trim().toUpperCase();

const normWhCode = (code: string | null | undefined): string =>
(code ?? "").trim().toUpperCase();

/** Match exact warehouse codes or floor vs bin (e.g. 4F ↔ 4F-W401). */
export const warehouseCodesMatch = (
a: string | null | undefined,
b: string | null | undefined,
): boolean => {
const x = normWhCode(a);
const y = normWhCode(b);
if (!x || !y) return false;
if (x === y) return true;
return x.startsWith(`${y}-`) || y.startsWith(`${x}-`);
};

/** Transfer destination stock-in is shown on the TRANSFER node, not as a separate putaway card. */
export const isTransferInboundPutaway = (refType: string | null | undefined): boolean =>
normRefType(refType) === "TRANSFER";

export type TraceTransferMatchInput = {
fromWarehouse?: string | null;
toWarehouse?: string | null;
qty?: number | null;
transferCode?: string | null;
timestamp?: string | null;
};

/**
* Putaway refCode is often SI-* while transferCode is TR-*; match by code, then to-warehouse + qty/time.
*/
export const matchTransferForInboundPutaway = (
putaway: {
warehouseCode?: string | null;
qty?: number | null;
refCode?: string | null;
timestamp?: string | null;
},
transfers: TraceTransferMatchInput[],
): TraceTransferMatchInput | undefined => {
if (!transfers.length) return undefined;
const ref = (putaway.refCode ?? "").trim();
if (ref) {
const byCode = transfers.find((tr) => (tr.transferCode ?? "").trim() === ref);
if (byCode) return byCode;
}

const toMatched = transfers.filter((tr) =>
warehouseCodesMatch(tr.toWarehouse, putaway.warehouseCode),
);
if (!toMatched.length) return undefined;
if (toMatched.length === 1) return toMatched[0];

const putawayQty = putaway.qty;
const qtyMatched =
putawayQty != null
? toMatched.filter((tr) => tr.qty != null && Number(tr.qty) === Number(putawayQty))
: [];
const pool = qtyMatched.length > 0 ? qtyMatched : toMatched;

const putawayTs = Date.parse(putaway.timestamp ?? "");
if (!Number.isNaN(putawayTs)) {
const ranked = pool
.map((tr) => {
const ts = Date.parse(tr.timestamp ?? "");
return { tr, dist: Number.isNaN(ts) ? Number.POSITIVE_INFINITY : Math.abs(ts - putawayTs) };
})
.sort((a, b) => a.dist - b.dist);
if (ranked[0] && Number.isFinite(ranked[0].dist)) return ranked[0].tr;
}
return pool[0];
};

/**
* Emit a TRANSFER card for each transfer on the primary lot.
* Location-block scopes skip transfers to avoid duplicating the same TR-* rows.
*/
export const shouldEmitTransferForScope = (locationBlockKeys?: boolean): boolean =>
!locationBlockKeys;

/**
* Transfer inbound putaways are represented by TRANSFER cards (one per TR-*).
* Keep 轉倉入庫 putaway cards off so multi-transfer lots do not collapse to one inbound.
*/
export const shouldShowTransferInboundPutawayCard = (
_scopeWarehouse?: string | null,
_putawayWarehouse?: string | null,
_transfers?: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>,
_mergedMultiLocation?: boolean,
): boolean => false;

/**
* In a merged multi-warehouse graph, skip 已用完 when this location was emptied by transfer
* to another location still shown on the same graph. Remaining DO picks belong to the destination.
*/
export const shouldEmitDepletedForScope = (
availableQty: number,
scopeWarehouse: string | null | undefined,
transfers: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>,
mergedMultiLocation: boolean,
): boolean => {
if (availableQty > 0) return false;
if (!mergedMultiLocation) return true;
const scope = scopeWarehouse?.trim();
if (!scope) return true;
const emptiedByTransfer = transfers.some((tr) =>
warehouseCodesMatch(scope, tr.fromWarehouse),
);
return !emptiedByTransfer;
};

export type PutawayPresentationLabels = {
nodePutaway: string;
nodePutawayTransfer: string;
putawayTransferDetail: string;
};

export type PutawayPresentation = {
/** Chip on flow card (top badge). */
chipLabel: string;
/** Status row in node detail panel. */
statusDetail: string;
/** Card title (replaces generic「上架」for transfer inbound). */
title: string;
/** Type row in node detail panel. */
detailType: string;
};

export const isPendingPutawayOriginStatus = (status: string | null | undefined): boolean => {
const normalized = (status ?? "").trim().toLowerCase();
if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) return false;
return true;
};

export const resolvePutawayStatusLabel = (
tr: TraceLabelTranslator,
status: string | null | undefined,
): string => {
const normalized = (status ?? "").trim().toLowerCase();
if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) {
return tr.putawayShelfStatus("completed");
}
return tr.putawayShelfStatus("pending");
};

export const resolvePutawayPresentation = (
tr: TraceLabelTranslator,
labels: PutawayPresentationLabels,
status: string | null | undefined,
refType: string | null | undefined,
): PutawayPresentation => {
if (normRefType(refType) === "TRANSFER") {
return {
chipLabel: labels.nodePutawayTransfer,
statusDetail: labels.putawayTransferDetail,
title: labels.nodePutawayTransfer,
detailType: labels.nodePutawayTransfer,
};
}
const shelf = resolvePutawayStatusLabel(tr, status);
return {
chipLabel: shelf,
statusDetail: shelf,
title: labels.nodePutaway,
detailType: labels.nodePutaway,
};
};

+ 38
- 0
src/components/ItemTracing/traceQtyUtils.ts Zobrazit soubor

@@ -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;
};

+ 166
- 0
src/components/ItemTracing/traceStockTakeUtils.ts Zobrazit soubor

@@ -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;
};

+ 22
- 1
src/components/Jodetail/JodetailSearch.tsx Zobrazit soubor

@@ -1,6 +1,7 @@
"use client";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import {
@@ -52,6 +53,20 @@ type SearchParamNames = keyof SearchQuery;

const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
const { t } = useTranslation("jo");
const searchParams = useSearchParams();
const urlTabRaw = searchParams.get("tab");
const urlPickOrderCode = searchParams.get("pickOrderCode")?.trim() || undefined;
const urlTargetDateRaw = searchParams.get("targetDate")?.trim() || undefined;
const urlTargetDate = (() => {
if (!urlTargetDateRaw) return undefined;
const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : undefined;
})();
const urlTabIndex = useMemo(() => {
if (urlTabRaw == null) return undefined;
const n = parseInt(urlTabRaw, 10);
return !Number.isNaN(n) && n >= 0 && n <= 3 ? n : undefined;
}, [urlTabRaw]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;

@@ -61,7 +76,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
//const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders);
const [filterArgs, setFilterArgs] = useState<Record<string, any>>({});
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [tabIndex, setTabIndex] = useState(0);
const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0);
const [totalCount, setTotalCount] = useState<number>();
const [isAssigning, setIsAssigning] = useState(false);
const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]);
@@ -70,6 +85,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
const [hasDataTab0, setHasDataTab0] = useState(false);
const [hasDataTab1, setHasDataTab1] = useState(false);
const hasAnyAssignedData = hasDataTab0 || hasDataTab1;

useEffect(() => {
if (urlTabIndex != null) setTabIndex(urlTabIndex);
}, [urlTabIndex]);
// Add printer selection state
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
@@ -492,6 +511,8 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
printerCombo={printerCombo}
selectedPrinter={selectedPrinter}
printQty={printQty}
initialPickOrderCode={urlPickOrderCode}
initialTargetDate={urlTargetDate}
/>
)}
{tabIndex === 2 && <MaterialPickStatusTable />}


+ 36
- 5
src/components/Jodetail/completeJobOrderRecord.tsx Zobrazit soubor

@@ -58,6 +58,8 @@ interface Props {
printerCombo: PrinterCombo[];
selectedPrinter?: PrinterCombo | null;
printQty?: number;
initialPickOrderCode?: string;
initialTargetDate?: string;
}

// 修改:已完成的 Job Order Pick Order 接口
@@ -112,11 +114,13 @@ interface LotDetail {
match_status: string | null;
}

const CompleteJobOrderRecord: React.FC<Props> = ({
const CompleteJobOrderRecord: React.FC<Props> = ({
filterArgs,
printerCombo,
selectedPrinter: selectedPrinterProp,
printQty: printQtyProp
printQty: printQtyProp,
initialPickOrderCode,
initialTargetDate,
}) => {
const { t } = useTranslation("jo");
const router = useRouter();
@@ -135,15 +139,23 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
const [detailLotDataLoading, setDetailLotDataLoading] = useState(false);
// 修改:搜索状态
const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => ({
completedDate: dayjs().format("YYYY-MM-DD"),
}));
const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => {
const raw = initialTargetDate?.trim() || "";
const match = raw.match(/(\d{4}-\d{2}-\d{2})/);
return {
completedDate: match?.[1] || dayjs().format("YYYY-MM-DD"),
...(initialPickOrderCode?.trim()
? { pickOrderCode: initialPickOrderCode.trim() }
: {}),
};
});
const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]);
// Use props with fallback
const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null);
const printQty = printQtyProp ?? 1;
const pickRecordPrintInFlightRef = useRef(false);
const initialDetailOpenedRef = useRef(false);

// 修改:分页状态
const [paginationController, setPaginationController] = useState({
@@ -370,6 +382,25 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
}, [fetchLotDetailsData]);

useEffect(() => {
if (initialDetailOpenedRef.current || completedJobOrderPickOrdersLoading) return;
const code = initialPickOrderCode?.trim();
if (!code) return;
const match = completedJobOrderPickOrders.find(
(row) =>
row.pickOrderCode?.trim() === code ||
row.pickOrderConsoCode?.trim() === code,
);
if (!match) return;
initialDetailOpenedRef.current = true;
void handleDetailClick(match);
}, [
completedJobOrderPickOrders,
completedJobOrderPickOrdersLoading,
initialPickOrderCode,
handleDetailClick,
]);

// 修改:返回列表视图
const handleBackToList = useCallback(() => {
setShowDetailView(false);


+ 8
- 1
src/components/NavigationContent/NavigationContent.tsx Zobrazit soubor

@@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => {
icon: <Storefront />,
labelKey: "nav.storeManagement",
path: "",
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ITEM_TRACING, AUTH.ADMIN],
children: [
{
id: "nav.store.purchaseOrder",
@@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/inventory",
},
{
id: "nav.store.itemTracing",
icon: <QrCodeIcon />,
labelKey: "nav.store.itemTracing",
requiredAbility: [AUTH.ITEM_TRACING],
path: "/itemTracing",
},
{
id: "nav.store.stockTake",
icon: <AssignmentTurnedIn />,


+ 312
- 0
src/i18n/en/itemTracing.json Zobrazit soubor

@@ -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"
}

+ 2
- 0
src/i18n/en/navigation.json Zobrazit soubor

@@ -5,6 +5,7 @@
"nav.store.purchaseOrder": "Purchase Order",
"nav.store.pickOrder": "Pick Order",
"nav.store.inventoryLedger": "View item In-out And inventory Ledger",
"nav.store.itemTracing": "Item Tracing",
"nav.store.stockTake": "Stock Take Management",
"nav.store.stockIssue": "Stock Issue",
"nav.store.putAwayScan": "Put Away Scan",
@@ -81,6 +82,7 @@
"nav.breadcrumb.schedulingDetailed": "Detail Scheduling",
"nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule",
"nav.breadcrumb.inventory": "Inventory",
"nav.breadcrumb.itemTracing": "Item Tracing",
"nav.breadcrumb.importTesting": "Import Testing",
"nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search",
"nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick",


+ 313
- 0
src/i18n/zh/itemTracing.json Zobrazit soubor

@@ -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": "最近異動"
}

+ 2
- 0
src/i18n/zh/navigation.json Zobrazit soubor

@@ -21,6 +21,7 @@
"nav.breadcrumb.home": "總覽",
"nav.breadcrumb.importTesting": "匯入測試",
"nav.breadcrumb.inventory": "存貨",
"nav.breadcrumb.itemTracing": "批號追溯",
"nav.breadcrumb.joEdit": "工單詳情",
"nav.breadcrumb.joTesting": "工單測試",
"nav.breadcrumb.joWorkbench": "工單工作台",
@@ -91,6 +92,7 @@
"nav.store.doWorkbench": "成品出倉",
"nav.store.finishedGoodManagement": "成品出倉管理",
"nav.store.inventoryLedger": "查看物品出入庫及庫存日誌",
"nav.store.itemTracing": "批號追溯",
"nav.store.pickOrder": "提料單",
"nav.store.purchaseOrder": "採購單",
"nav.store.putAwayScan": "上架掃碼",


+ 57
- 0
src/utils/traceDoOutboundExtra.ts Zobrazit soubor

@@ -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;
};

Načítá se…
Zrušit
Uložit